Linux 基础体系 · 第 70/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux TLS 与证书运维:PKI、链、SNI、OCSP、续期和排障

TLS(Transport Layer Security)解决的不是“给网站加一个证书”这么简单,而是一组相互配合的机制:

  1. 通过密码学协商出会话密钥;
  2. 通过证书证明服务端公钥与某个身份绑定;
  3. 通过证书链把该身份追溯到客户端信任的根;
  4. 通过握手认证、加密和完整性保护传输数据;
  5. 通过吊销状态、续期和部署流程管理证书生命周期。

Linux 上的 Nginx、Apache、HAProxy、Envoy、应用服务器以及部分代理软件通常都依赖 OpenSSL、BoringSSL、GnuTLS 或系统信任库。不同实现的配置参数不同,但证书验证、链构建、SNI 和 OCSP 的基本因果关系是一致的。


一、先区分几个容易混淆的概念

1. TLS、HTTPS、证书和私钥

TLS 是安全传输协议。它可以承载 HTTP、SMTP、IMAP、LDAP 等多种应用协议。

HTTPS 是“HTTP over TLS”,即先建立 TLS 连接,再在其中传输 HTTP。

证书通常指 X.509 证书。它包含:

  • 主体名称;
  • 公钥;
  • 有效期;
  • 签发者;
  • 用途限制;
  • 主体备用名称;
  • 签名;
  • 吊销信息地址等扩展。

私钥是与证书中公钥配对的秘密数据。证书可以公开分发,私钥不能公开。

TLS 服务端证明身份时,并不是把私钥发送给客户端,而是使用私钥完成签名或密钥交换相关操作。客户端使用证书中的公钥验证结果。

因此:

证书泄露        通常不会直接导致服务端身份失守
私钥泄露        可能导致攻击者冒充服务端
证书过期        通常导致客户端拒绝连接
私钥与证书不匹配  服务启动失败或握手失败

可以用以下命令检查公钥是否匹配:

openssl x509 -in server.crt -pubkey -noout > /tmp/cert.pub
openssl pkey -in server.key -pubout > /tmp/key.pub
diff -u /tmp/cert.pub /tmp/key.pub

没有输出表示公钥相同。对于 RSA 密钥,也可以比较模数,但比较标准化后的公钥更适用于不同密钥类型。

2. 加密不等于身份认证

TLS 同时提供:

  • 机密性:旁路观察者不能直接读取应用数据;
  • 完整性:攻击者不能在不被发现的情况下修改数据;
  • 身份认证:客户端可以验证自己连接的是哪个服务端。

只加密、不验证身份仍然可能遭受中间人攻击。攻击者可以建立一条“客户端—攻击者”和一条“攻击者—真实服务端”的独立加密连接。

证书体系的核心作用就是把:

域名或服务身份
        ↓
服务端公钥
        ↓
由受信任 CA 签名的证书

绑定起来。


二、PKI:证书为什么能证明身份

1. PKI 的参与者

PKI(Public Key Infrastructure,公钥基础设施)通常包含以下角色:

  • 实体:例如 www.example.com 的服务端;
  • 密钥对:私钥和公钥;
  • 证书申请者:向 CA 请求证书的一方;
  • CA(Certificate Authority):签发证书的证书机构;
  • 根 CA:信任链最顶端,通常由操作系统、浏览器或组织管理员预置;
  • 中间 CA:由根 CA 签发、实际负责签发服务端证书的 CA;
  • 验证者:客户端或服务端,用于验证证书链;
  • 吊销服务:CRL 或 OCSP,用于发布证书是否被撤销。

公共网站通常不会直接使用根 CA 签发业务证书,而是采用:

根 CA
  └── 中间 CA
        └── www.example.com 服务端证书

这样做可以让根 CA 长期离线保存,中间 CA 负责日常签发;中间 CA 泄露时也可以单独吊销或替换。

2. 证书签发过程

以网站证书为例,完整流程如下:

  1. 服务端生成私钥;
  2. 根据私钥导出公钥;
  3. 生成 CSR(Certificate Signing Request);
  4. CSR 中包含公钥、申请的域名和申请者签名;
  5. CA 验证申请者控制该域名;
  6. CA 使用 CA 私钥签发 X.509 证书;
  7. 服务端保存私钥和证书;
  8. 客户端使用自己的信任库验证证书。

生成私钥和 CSR 的示例:

umask 077

openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out server.key

openssl req \
  -new \
  -key server.key \
  -out server.csr \
  -subj "/CN=www.example.com" \
  -addext "subjectAltName=DNS:www.example.com"

这里的 umask 077 使新文件默认只允许当前用户访问。生产环境中,私钥通常还应放在专用目录,并由服务进程以最小权限读取。

CSR 中的 CN 仍然常见,但现代客户端主要根据 subjectAltName(SAN)验证域名。只填写 CN 而没有 SAN,可能在现代客户端中验证失败。

查看 CSR:

openssl req -in server.csr -noout -text

CSR 自身不是证书,也不能直接用于 TLS 服务。它是提交给 CA 的申请材料。

3. CA 如何证明申请者控制域名

ACME 等自动签发协议通常使用以下挑战方式:

  • HTTP-01:CA 请求 http://域名/.well-known/acme-challenge/...
  • DNS-01:申请者在 DNS 中发布指定 TXT 记录;
  • TLS-ALPN-01:通过特定 TLS 握手证明控制权。

HTTP-01 适合普通公网 HTTP 服务;泛域名证书通常需要 DNS-01。DNS-01 的风险是自动化系统必须拥有修改 DNS 的权限,DNS API 凭据泄露后影响范围可能大于单个 Web 服务器。


三、X.509 证书到底验证什么

客户端验证证书时,不是简单检查“签名是否正确”。至少要同时考虑以下条件:

  1. 能否构造从服务端证书到信任根的合法链;
  2. 每个签名是否有效;
  3. 当前时间是否落在证书有效期内;
  4. 目标主机名是否匹配 SAN;
  5. basicConstraints 是否允许该证书作为 CA;
  6. keyUsageextendedKeyUsage 是否允许当前用途;
  7. 是否被吊销,具体取决于客户端策略;
  8. 算法、密钥长度和签名算法是否被客户端接受;
  9. 链中是否违反路径长度、名称约束等限制。

可以把一次典型验证抽象为:

验证成功 =
  链可构造
  ∧ 签名正确
  ∧ 时间有效
  ∧ 主机名匹配
  ∧ 用途正确
  ∧ 算法策略允许
  ∧ 吊销状态满足客户端策略

其中任意一项失败,都可能导致连接被拒绝。

1. 主机名验证

假设证书有:

Subject Alternative Name:
    DNS:example.com
    DNS:*.example.com

它通常匹配:

example.com
www.example.com
api.example.com

但不匹配:

a.b.example.com
example.net

通配符通常只覆盖一个 DNS 标签,不能跨越多级子域。*.example.com 不应被理解为任意深度匹配。

客户端验证的是它实际连接的主机名,而不是证书的 CN 文本。使用 IP 地址访问时,证书通常必须在 SAN 中包含:

IP Address:192.0.2.10

仅写 DNS:192.0.2.10 并不等价。

2. 证书用途

CA 证书通常包含:

X509v3 Basic Constraints: critical
    CA:TRUE

服务端叶子证书通常包含:

X509v3 Basic Constraints:
    CA:FALSE

X509v3 Extended Key Usage:
    TLS Web Server Authentication

如果把普通服务端证书当作中间 CA 使用,客户端可能因为 CA:FALSE 而拒绝构建链。

查看证书的关键字段:

openssl x509 \
  -in server.crt \
  -noout \
  -subject \
  -issuer \
  -serial \
  -dates \
  -fingerprint -sha256 \
  -ext subjectAltName,basicConstraints,keyUsage,extendedKeyUsage,authorityInfoAccess

四、证书链:服务端到底应该发送什么

1. 根证书、中间证书和叶子证书

三类证书的职责不同:

  • 根证书:客户端信任库中预置的信任锚;
  • 中间证书:帮助客户端从叶子证书走到根;
  • 叶子证书:通常是网站或服务自身的证书。

服务端一般应发送:

叶子证书
中间证书 1
中间证书 2
...

通常不需要发送根证书,因为客户端应该从自己的信任库获得根证书。

正确的链顺序是“从服务端证书向上”:

server.example.com
    ↓ issuer
Intermediate CA
    ↓ issuer
Root CA

不能把多个无关证书随意拼接,也不能只发送根证书而省略中间证书。

2. 为什么浏览器能打开,某些程序却失败

浏览器可能:

  • 自带独立信任库;
  • 曾经缓存过中间证书;
  • 具备额外的链构建逻辑;
  • 使用不同的吊销策略。

而 Linux 上的 Java、Go、Python、curl、OpenSSL 可能使用不同的信任库或验证策略。因此“浏览器正常”不能证明服务端链配置正确。

常见失败表现:

unable to get local issuer certificate
unable to verify the first certificate
certificate verify failed

这些错误经常意味着服务端没有发送必要的中间证书,但也可能是客户端信任库缺失或代理替换了证书。

3. 验证证书链

假设文件如下:

server.crt       # 叶子证书
intermediate.crt # 中间 CA
root.crt         # 根 CA

使用 OpenSSL 验证:

openssl verify \
  -CAfile root.crt \
  -untrusted intermediate.crt \
  server.crt

预期输出:

server.crt: OK

这里:

  • -CAfile 提供信任锚;
  • -untrusted 提供构链所需的中间证书;
  • server.crt 是待验证叶子证书。

-untrusted 中的证书会用于构链,但不会自动成为信任根。这一区分很重要。

检查证书之间是否首尾相接:

openssl x509 -in server.crt -noout -subject -issuer
openssl x509 -in intermediate.crt -noout -subject -issuer
openssl x509 -in root.crt -noout -subject -issuer

应满足:

server.crt 的 issuer       = intermediate.crt 的 subject
intermediate.crt 的 issuer = root.crt 的 subject

仅比较名称还不够,最终仍需用签名验证命令确认。

4. Nginx 中的链文件

常见 Nginx 配置:

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

    ssl_certificate     /etc/nginx/tls/www.example.com.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/www.example.com.key;

    location / {
        proxy_pass http://127.0.0.1:8080;
    }
}

fullchain.pem 通常应是:

叶子证书
中间证书

而不是:

中间证书
叶子证书

修改后先检查配置,再平滑加载:

sudo nginx -t
sudo systemctl reload nginx

reload 通常让新连接使用新配置,已有连接继续处理;但具体行为由服务实现决定。不要在未执行 nginx -t 的情况下直接重启生产服务,因为配置错误可能造成整体中断。


五、TLS 握手:证书何时发送,密钥如何建立

1. TLS 1.2 的简化流程

TLS 1.2 握手可能包含:

客户端                              服务端
  | -------- ClientHello ----------> |
  | <------- ServerHello ----------- |
  | <------- Certificate ----------- |
  | <------- ServerKeyExchange ----- |
  | <------- ServerHelloDone ------- |
  | -------- ClientKeyExchange ----> |
  | -------- ChangeCipherSpec -----> |
  | -------- Finished -------------> |
  | <------- ChangeCipherSpec ------ |
  | <------- Finished ------------- |

现代部署通常使用 ECDHE。服务端使用证书私钥签名临时密钥交换参数,客户端验证签名后,双方根据临时密钥计算会话密钥。

证书私钥并不直接用于加密所有业务数据。业务数据通常使用高效的对称加密算法,例如 AES-GCM 或 ChaCha20-Poly1305。

2. TLS 1.3 的简化流程

TLS 1.3 将握手消息压缩为更少的往返:

客户端                              服务端
  | -------- ClientHello ----------> |
  | <--- ServerHello, Encrypted ---- |
  | <--- Certificate --------------- |
  | <--- CertificateVerify --------- |
  | <--- Finished ------------------ |
  | -------- Finished -------------> |

TLS 1.3 中,服务端证书和后续握手内容通常在握手密钥建立后传输,因此比 TLS 1.2 更早保护握手内容。但 ClientHello 中的 SNI 在没有使用 ECH 时仍可能暴露给网络观察者。

TLS 1.3 不再使用 TLS 1.2 中的一些旧式密码套件命名方式,密钥交换固定采用现代设计,服务端证书签名算法仍然需要客户端支持。

3. 会话恢复

TLS 支持会话恢复,用于避免每次连接都完整执行昂贵的握手。TLS 1.3 使用 PSK 和 ticket 等机制。

会话恢复会带来几个运维边界:

  • 新证书不一定立即影响已经恢复的会话;
  • 旧进程可能继续使用已加载的证书和会话票据;
  • 负载均衡集群可能需要协调票据密钥或接受跨节点恢复失败;
  • 证书轮换应同时验证新建连接和恢复连接。

不能只测试浏览器中已经打开的页面来判断新证书是否生效,应明确建立新的 TCP/TLS 连接。


六、SNI:同一个 IP 如何承载多个证书

1. SNI 的作用

SNI(Server Name Indication)是 TLS ClientHello 中的扩展,客户端在握手开始时声明目标主机名:

客户端请求 www.example.com
        ↓
ClientHello.server_name = www.example.com
        ↓
服务端选择对应的证书和 TLS 配置

因为 HTTP 请求中的 Host 头要在 TLS 握手完成后才能读取,所以服务端不能依赖 HTTP Host 头来决定最初的证书。

一个 IP 可以因此承载:

www.example.com  → 证书 A
api.example.com  → 证书 B
admin.example.com → 证书 C

2. SNI 与 Nginx server

示例:

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

    ssl_certificate     /etc/nginx/tls/www.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/www.key;
}

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

    ssl_certificate     /etc/nginx/tls/api.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/api.key;
}

Nginx 根据监听地址、端口和 SNI 选择虚拟服务器。若客户端不发送 SNI,或者 SNI 没有匹配项,通常会使用该监听端口的默认服务器证书。

因此,以下现象并不矛盾:

https://www.example.com 正常
openssl s_client -connect 203.0.113.10:443 失败或显示另一张证书

后一个命令没有发送 SNI,服务端返回默认站点证书。

正确测试方式:

openssl s_client \
  -connect 203.0.113.10:443 \
  -servername www.example.com \
  -showcerts \
  -verify_return_error </dev/null

-connect 决定 TCP 连接到哪里,-servername 决定 ClientHello 中声明哪个主机名。两者可以不同,这对测试“指定 IP 上的某个虚拟主机”很有用。

也可以使用 curl:

curl -v --resolve www.example.com:443:203.0.113.10 \
  https://www.example.com/

--resolve 只改变 DNS 解析结果,仍会发送 www.example.com 作为 SNI 和 HTTP 主机名。

3. SNI 的边界

SNI 不能解决所有多租户问题:

  • 客户端必须支持并发送 SNI;
  • 服务端必须正确配置默认站点;
  • 证书选择发生在 HTTP 之前;
  • 某些非 HTTP TLS 协议还有自己的协议协商要求;
  • SNI 不等于 HTTP Host,二者不一致时可能出现路由与证书不一致。

ALPN(Application-Layer Protocol Negotiation)则用于协商应用层协议,例如 HTTP/2 或 HTTP/1.1。SNI 主要选择身份和配置,ALPN 主要选择承载的应用协议,两者不是同一个扩展。


七、双向 TLS:客户端证书和服务端证书不是一回事

普通 HTTPS 通常只认证服务端:

客户端验证服务端证书
服务端不要求客户端证书

mTLS(mutual TLS,双向 TLS)则要求:

客户端验证服务端证书
服务端验证客户端证书

服务端在握手中发送 CertificateRequest,客户端返回客户端证书和对应私钥的签名证明。

服务端验证客户端证书时,通常还要检查:

  • 客户端证书链;
  • 客户端证书是否匹配受信 CA;
  • extendedKeyUsage 是否包含客户端认证用途;
  • 证书是否过期或被吊销;
  • 证书中的组织、组或其他身份字段是否满足应用授权规则。

“证书验证成功”不等于“业务授权成功”。例如,证书可以证明客户端属于某个组织,但应用仍需根据 SAN、OU、SPIFFE URI 或自定义身份映射决定它能访问哪些 API。


八、OCSP、CRL 和证书吊销

1. 为什么需要吊销

证书在有效期内也可能失效,例如:

  • 私钥泄露;
  • CA 错误签发;
  • 域名控制权变化;
  • 服务端不再属于原组织。

仅依赖 notAfter 无法表达“尚未过期但已经不应信任”,因此出现了吊销机制。

2. CRL 和 OCSP 的区别

CRL(Certificate Revocation List) 是 CA 定期发布的证书序列号黑名单。

优点:

  • 可以离线验证;
  • 结构简单。

缺点:

  • 文件可能较大;
  • 更新周期带来延迟;
  • 客户端需要下载并缓存。

OCSP(Online Certificate Status Protocol) 是客户端针对某张证书向 CA 的 OCSP responder 查询状态:

客户端或服务端
    ↓ 查询证书序列号
OCSP responder
    ↓ good / revoked / unknown
返回状态和签名证明

OCSP 响应通常包含:

  • 查询的证书标识;
  • goodrevokedunknown
  • thisUpdate;
  • nextUpdate;
  • OCSP responder 的签名。

good 的含义是“在该响应覆盖的时间范围内,未被 responder 标记为吊销”,不是永久保证,也不等于证书所有其他验证条件都正确。

证书中的 OCSP 地址可以查看:

openssl x509 -in server.crt -noout -ocsp_uri

也可以查看完整的 Authority Information Access:

openssl x509 -in server.crt -noout -text | sed -n '/Authority Information Access/,+8p'

3. OCSP 的实际限制

吊销检查在现实中并不总是严格在线阻断:

  • 有些客户端默认不做实时 OCSP;
  • 有些客户端采用 soft-fail,查询失败时仍继续连接;
  • 有些浏览器使用自己的吊销机制;
  • 代理、网络策略或 DNS 问题可能阻断 OCSP responder;
  • OCSP responder 自身不可用时,客户端行为取决于实现和策略。

因此:

没有 OCSP 查询失败 ≠ 证书一定没有被吊销
OCSP 查询成功     ≠ 主机名、签名链和有效期一定正确

4. OCSP stapling

OCSP stapling 让服务端预先向 CA 获取 OCSP 响应,并在 TLS 握手中发送给客户端:

服务端 ──定期查询──> OCSP responder
服务端 ──握手携带──> 客户端

这样客户端不必直接连接 CA 的 responder,减少延迟、隐私泄露和外连依赖。

Nginx 的典型配置:

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

    ssl_certificate     /etc/nginx/tls/www.fullchain.pem;
    ssl_certificate_key /etc/nginx/tls/www.key;

    ssl_stapling on;
    ssl_stapling_verify on;

    # Nginx 需要能够解析并访问 OCSP responder
    resolver 1.1.1.1 1.0.0.1 valid=300s;
}

真实环境中应使用组织允许的 DNS resolver,并考虑出口防火墙、代理和 IPv6 路由。开启 stapling 不代表一定能产生有效响应;证书没有 OCSP 地址、链配置不完整、DNS 失败或 responder 不可达都可能导致 stapling 无效。

检查服务端是否发送 stapled response:

openssl s_client \
  -connect www.example.com:443 \
  -servername www.example.com \
  -status </dev/null 2>/dev/null | \
  sed -n '/OCSP response:/,/---/p'

如果看到:

OCSP Response Status: successful
...
Cert Status: good

表示服务端返回了可解析的 OCSP 响应。若输出:

OCSP response: no response sent

则没有 stapling 响应,但这不一定表示 TLS 证书本身错误。

curl 在支持相应功能时可以要求服务端提供 stapling:

curl -v --cert-status https://www.example.com/

这会把“没有有效 stapled status”视为失败,适合验证部署要求,但不应在不了解客户端兼容性的情况下直接作为所有业务客户端的默认策略。

5. OCSP Must-Staple

部分证书可以通过 TLS Feature 扩展声明客户端必须获得 stapled OCSP 响应,常称为 OCSP Must-Staple。

这会提高吊销状态传递的确定性,但也增加运维风险:

服务端 stapling 配置或刷新失败
        ↓
客户端严格执行 Must-Staple
        ↓
即使证书和私钥正确,连接也可能失败

如果使用这类证书,必须把 OCSP 响应刷新、监控和故障恢复纳入证书部署流程。


九、证书续期:获取新证书只是流程的一半

1. 续期的完整生命周期

证书续期至少包含:

检查剩余有效期
    ↓
生成或复用私钥
    ↓
完成域名控制权验证
    ↓
获取新证书和中间链
    ↓
验证证书、链、主机名和用途
    ↓
以正确权限安装
    ↓
测试服务配置
    ↓
平滑加载服务
    ↓
建立新连接验证线上证书
    ↓
监控旧证书是否仍被使用

最常见的错误是只完成“下载新证书”,却没有让正在运行的进程重新读取它。

2. ACME 客户端的职责

Certbot、acme.sh、lego、Caddy 等工具都可以实现 ACME 自动续期,但它们的目录结构、权限模型和 deploy hook 不同。不要假设某个工具一定使用固定路径或固定 systemd 单元。

续期前先检查工具实际管理的证书:

sudo certbot certificates

如果使用 Certbot,可以先执行模拟续期:

sudo certbot renew --dry-run

--dry-run 主要验证续期流程和 CA 交互,不等价于验证线上 Nginx 已加载新证书。

续期成功后需要配置部署钩子。例如:

sudo certbot renew \
  --deploy-hook "nginx -t && systemctl reload nginx"

生产环境中通常将钩子写入受控的 deploy-hook 目录或 systemd 配置,而不是依赖人工临时执行。

3. 原子安装和权限

证书部署时应避免直接覆盖正在使用的文件。更安全的模式是:

install -o root -g root -m 0644 new.fullchain.pem \
  /etc/nginx/tls/www.example.com.fullchain.pem

install -o root -g root -m 0600 new.key \
  /etc/nginx/tls/www.example.com.key

更严格的流程可以先写入临时文件,验证后再原子替换:

install -o root -g root -m 0644 new.fullchain.pem \
  /etc/nginx/tls/.www.fullchain.pem.new

openssl verify \
  -CAfile /etc/ssl/certs/ca-certificates.crt \
  -untrusted intermediate.crt \
  /etc/nginx/tls/.www.fullchain.pem.new

验证成功后再使用同一文件系统上的原子重命名。不同发行版的系统信任库路径不同,例如 Debian/Ubuntu 常见为:

/etc/ssl/certs/ca-certificates.crt

RHEL、CentOS、Fedora 常见为:

/etc/pki/tls/certs/ca-bundle.crt

应用程序如果以非 root 用户运行,私钥文件应通过组权限、ACL 或专用目录授予最小读取权限。不要为了“先让服务启动”而执行:

chmod 644 server.key

这会把私钥暴露给本机所有用户。

4. reload、restart 和进程状态

很多 TLS 服务在启动或 reload 时将证书和私钥读入进程内存。仅替换磁盘文件通常不会改变现有进程使用的证书。

因此应区分:

  • 替换文件:改变磁盘上的数据;
  • reload:让服务重新读取配置和证书,通常不中断已有连接;
  • restart:停止再启动,可能中断连接;
  • 新连接验证:确认外部观察到的证书确实已改变。

Nginx 示例:

sudo nginx -t
sudo systemctl reload nginx
sudo systemctl is-active nginx

随后检查线上证书:

echo | openssl s_client \
  -connect www.example.com:443 \
  -servername www.example.com 2>/dev/null |
openssl x509 -noout -subject -issuer -dates -serial -fingerprint -sha256

不能只检查本地文件的序列号,因为负载均衡器、CDN、反向代理或另一台节点可能仍在提供旧证书。


十、Linux 信任库与应用信任库

1. 系统信任库不是一个统一文件

不同发行版和应用可能采用不同路径:

  • Debian/Ubuntu 常见使用 ca-certificates
  • RHEL/Fedora 常见使用 ca-certificates 和系统 trust store;
  • Java 通常使用自己的 cacerts
  • 容器镜像可能根本没有安装 CA 包;
  • Python、Go、Node.js 可能分别调用系统库、运行时库或自带机制。

检查系统已安装的 CA 包:

# Debian/Ubuntu
dpkg -l ca-certificates

# RHEL/Fedora
rpm -q ca-certificates

更新系统 CA:

# Debian/Ubuntu
sudo update-ca-certificates

# RHEL/Fedora
sudo update-ca-trust

发行版命令和目录是实现差异,不应把某个路径写死在跨发行版脚本中。

2. 添加组织内部 CA

企业内网常使用私有根 CA。将根证书加入系统信任库后,许多系统程序可以验证内部服务,但以下程序仍可能需要单独配置:

  • Java;
  • 某些容器;
  • 浏览器;
  • 静态链接程序;
  • 使用自定义 CA bundle 的应用。

私有根 CA 的风险范围很大:一旦把恶意或错误证书加入根信任库,所有使用该信任库的连接都可能被冒充。因此应验证根证书指纹和来源:

openssl x509 -in company-root-ca.crt \
  -noout -subject -issuer -serial -fingerprint -sha256

不要把服务端叶子证书当作系统根 CA 安装。应安装真正的组织根 CA,或明确的中间 CA,并了解由此扩大了哪些信任范围。


十一、系统化排障:从 DNS 到应用层逐层定位

TLS 故障不应一开始就反复更换证书。应按数据流分层:

DNS
 ↓
TCP 连接
 ↓
TLS 版本和密码协商
 ↓
SNI 与证书选择
 ↓
证书链和主机名验证
 ↓
OCSP/CRL
 ↓
HTTP 或其他应用协议
 ↓
反向代理和后端

每一层都可能独立失败。

1. DNS 和 TCP

dig +short www.example.com
getent ahosts www.example.com

nc -vz www.example.com 443
# 或
timeout 5 bash -c '</dev/tcp/www.example.com/443'

如果 DNS 指向旧节点,修改证书不会影响实际访问。若 TCP 失败,应先检查安全组、防火墙、监听地址和负载均衡,而不是检查证书。

查看本机监听:

sudo ss -lntp | grep ':443'

2. 获取服务端实际发送的证书

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

重点观察:

Certificate chain
Server certificate
subject=
issuer=
Verify return code
New, TLSv1.3

不同结果的含义:

verify return code: 0 (ok)

表示 OpenSSL 在当前信任库和当前主机名之外的检查条件下,基本验证成功。但 s_client 的主机名检查需要显式指定参数或另用 -verify_hostname,不能仅凭这一行断言 hostname 一定正确。

可以执行:

openssl s_client \
  -connect www.example.com:443 \
  -servername www.example.com \
  -verify_hostname www.example.com \
  -verify_return_error </dev/null

如果看到:

no peer certificate available

说明握手在服务端发送证书前已经失败,可能是协议版本、密码套件、SNI、服务端配置或网络中间设备问题。

如果 subject 正确但 issuer 对应的中间证书没有出现在 Certificate chain 中,客户端可能在某些环境失败。

3. 用 curl 验证完整 HTTPS 行为

curl -v https://www.example.com/

curl 同时验证:

  • DNS 和 TCP;
  • TLS 握手;
  • 证书链;
  • 主机名;
  • HTTP 响应。

常见错误:

SSL certificate problem: unable to get local issuer certificate

优先检查服务端是否缺中间证书和客户端 CA 是否完整。

SSL: no alternative certificate subject name matches target host name

通常是访问主机名不在证书 SAN 中,或者 SNI 导致服务端选错证书。

wrong version number

常见于把明文 HTTP 端口当作 HTTPS 端口访问,也可能是代理或端口映射错误。它不等于“TLS 版本太新或太旧”。

handshake failure

可能由客户端和服务端没有共同支持的协议版本、密码套件、签名算法、客户端证书要求或策略限制导致,必须结合服务端日志和 s_client 输出判断。

4. 检查 SNI 差异

分别执行:

openssl s_client \
  -connect 203.0.113.10:443 \
  -servername www.example.com </dev/null 2>/dev/null |
openssl x509 -noout -subject -issuer -dates

openssl s_client \
  -connect 203.0.113.10:443 \
  </dev/null 2>/dev/null |
openssl x509 -noout -subject -issuer -dates

若两次证书不同,说明该地址存在基于 SNI 的虚拟主机选择。无 SNI 的旧客户端可能只能获得默认站点证书。

5. 检查私钥、证书、链和配置

sudo nginx -t

openssl x509 -in /etc/nginx/tls/server.crt -noout -dates
openssl x509 -in /etc/nginx/tls/server.crt -pubkey -noout > /tmp/cert.pub
openssl pkey -in /etc/nginx/tls/server.key -pubout > /tmp/key.pub
diff -u /tmp/cert.pub /tmp/key.pub

服务启动失败时查看日志:

sudo journalctl -u nginx -b --no-pager
sudo journalctl -u haproxy -b --no-pager

典型错误与原因:

PEM_read_bio_X509_AUX: no start line

文件不是有效 PEM,或路径指向了错误文件。

key values mismatch

私钥与证书不匹配。

cannot load certificate key

可能是权限、SELinux 标签、文件格式或密钥加密密码问题。

permission denied

不仅要检查文件权限,还要检查目录的执行权限、服务用户、ACL 和 SELinux。

6. 用抓包确认“卡在哪一步”

在授权的测试环境中:

sudo tcpdump -i any -nn -s0 -w /tmp/tls.pcap port 443

然后用 Wireshark 或 tshark 分析 ClientHello、ServerHello、Alert 和 TCP 重传。

注意:现代 TLS 会加密大部分应用内容,抓包通常仍能看到:

  • TCP 建连;
  • ClientHello;
  • SNI(未使用 ECH 时);
  • TLS 版本和扩展;
  • Alert;
  • 重传和连接关闭。

抓包文件可能含有域名、网络地址和业务时序信息,应按敏感数据处理。


十二、典型生产故障与因果链

故障一:续期成功,但线上仍是旧证书

可能路径:

ACME 客户端成功下载新证书
    ↓
新证书写入另一个目录
    ↓
Nginx 配置仍指向旧路径

或:

证书文件已经替换
    ↓
Nginx 没有 reload
    ↓
旧 worker 仍使用内存中的旧证书

验证方法:

sudo nginx -T | grep -E 'ssl_certificate(_key)?'
sudo stat /etc/nginx/tls/*
echo | openssl s_client \
  -connect example.com:443 \
  -servername example.com 2>/dev/null |
openssl x509 -noout -serial -dates

恢复步骤:

  1. 确认新证书路径和权限;
  2. 检查新证书与私钥匹配;
  3. 检查完整链;
  4. 执行 nginx -t
  5. 执行 reload;
  6. 从每个公网入口验证序列号和有效期。

故障二:浏览器正常,Java 服务报链错误

可能路径:

浏览器拥有缓存或独立中间证书
    ↓
浏览器能补齐链
Java truststore 或服务端收到的链不完整
    ↓
Java 验证失败

处理时应先修复服务端发送的链,而不是立即把服务器证书导入 Java truststore。将叶子证书导入客户端信任库会掩盖服务端配置错误,并增加后续轮换成本。

故障三:只有某个域名返回错误证书

可能路径:

客户端发送 SNI = api.example.com
    ↓
Nginx server_name 未匹配
    ↓
使用默认 server 的证书
    ↓
客户端发现 SAN 不包含 api.example.com

验证时必须带 -servername,并检查:

sudo nginx -T

同时确认 DNS、IPv4、IPv6、CDN 和负载均衡节点都已部署相同配置。只检查一台后端无法证明整个入口一致。

故障四:开启 OCSP stapling 后出现握手问题

可能路径:

Nginx 开启 ssl_stapling
    ↓
无法解析或访问 OCSP responder
    ↓
没有有效 stapled response
    ↓
严格客户端拒绝,或监控报警

检查:

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

同时检查:

  • 证书是否包含 OCSP URI;
  • Nginx 是否能解析 responder;
  • 出口防火墙是否允许访问;
  • 中间证书是否完整;
  • 时间同步是否正常;
  • 是否配置了 ssl_stapling_verify
  • 是否存在 Must-Staple 要求。

不要因为 OCSP responder 暂时不可用,就随意关闭所有证书验证或使用 curl -k 作为长期方案。-k 只适合明确隔离的诊断场景,它关闭了关键身份验证。


十三、时间同步是证书验证的一部分

证书有效期比较使用本机时间:

notBefore ≤ 当前时间 ≤ notAfter

如果服务器时钟落后,可能出现:

certificate is not yet valid

如果服务器时钟超前,可能提前出现:

certificate has expired

检查时间同步:

timedatectl status
date -u

现代 Linux 常见使用 chrony 或 systemd-timesyncd:

systemctl status chronyd
systemctl status systemd-timesyncd

时间同步服务本身也依赖网络和系统策略。虚拟机从宿主机继承错误时间、NTP 被防火墙阻断、闰秒或时区误判都可能导致 TLS 故障。证书中的时间通常以 UTC 表示,排障时应优先使用 date -u


十四、TLS 配置中的版本和安全边界

1. 不要把“支持更多版本”当作兼容性解决方案

现代服务通常优先启用 TLS 1.2 和 TLS 1.3,是否禁用 TLS 1.0/1.1 应结合客户端范围和合规要求。直接开启过时协议可能引入已知弱点或触发安全扫描失败。

Nginx 示例:

ssl_protocols TLSv1.2 TLSv1.3;

具体默认值和可用性取决于 Nginx 编译时链接的 TLS 库版本。应通过实际命令确认:

nginx -V 2>&1
openssl version -a

2. 密码套件配置具有版本敏感性

TLS 1.2 和 TLS 1.3 的密码套件配置方式不同。不要把 TLS 1.2 的 ssl_ciphers 规则直接理解为能完全控制 TLS 1.3。

测试协议:

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

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

若某个版本失败,应区分:

  • 服务端未启用该版本;
  • 客户端 OpenSSL 不支持;
  • 中间设备阻断;
  • 策略禁止算法;
  • 证书签名算法不兼容。

3. 前向保密

使用 ECDHE 等临时密钥交换时,每次会话使用临时密钥。即使将来获得服务端长期私钥,过去捕获的流量通常也不能直接被解密,这称为前向保密。

这不意味着私钥泄露没有影响。攻击者仍可能:

  • 冒充服务端建立新连接;
  • 进行主动中间人攻击;
  • 获取当前或未来会话内容;
  • 利用其他系统凭据扩大影响。

私钥泄露后的正确处理是立即停止使用、重新生成密钥、重新签发证书,并根据 CA 和组织流程吊销旧证书。


十五、私钥泄露与证书事故响应

1. 证书过期

处理顺序:

  1. 确认实际对外提供证书的节点;
  2. 检查 DNS、CDN、负载均衡和多活节点;
  3. 续期并获取完整链;
  4. 验证域名、用途和私钥匹配;
  5. 原子安装;
  6. 配置测试;
  7. reload;
  8. 对所有入口建立新连接验证;
  9. 检查监控和自动续期定时器。

2. 私钥泄露

私钥泄露的处理强度高于证书过期:

生成新私钥
    ↓
使用新私钥生成 CSR
    ↓
重新签发证书
    ↓
替换所有节点
    ↓
reload 或重启相关服务
    ↓
吊销旧证书
    ↓
审查访问日志、代理日志和密钥访问记录

不能只重新下载一份同公钥证书,因为攻击者仍持有旧私钥。

3. 删除旧私钥的时机

应先确认:

  • 所有节点已经切换;
  • 所有进程已经重新加载;
  • 自动化系统不再引用旧路径;
  • 备份和日志中没有不必要的私钥副本。

但也不能在恢复尚未完成时贸然删除唯一可用密钥。生产操作应保留受控、加密、访问审计的备份,并设置明确的销毁流程。


十六、与 OpenSSH 的关系:不要把 SSH 主机密钥当 TLS 证书

OpenSSH 不使用 HTTPS 那套 TLS 握手。SSH 有自己的协议、主机密钥、用户认证和信任模型。

SSH 首次连接时通常记录服务端主机密钥指纹:

~/.ssh/known_hosts

而 HTTPS 通常依赖:

X.509 证书链
系统或应用信任库
主机名验证

两者都解决“连接到了谁”的问题,但方式不同:

HTTPS:
域名 → X.509 证书 → CA 信任链

SSH:
主机地址 → known_hosts 中的主机公钥指纹

OpenSSH 也支持证书机制,但那是 SSH certificate,不是 TLS 的 X.509 证书,格式、签发工具和验证流程不同。不要把 /etc/ssh/ssh_host_* 私钥配置到 Nginx,也不要把网站证书复制给 sshd 作为主机身份。

SSH 的跳板、Agent 转发和隧道可能承载 HTTPS 流量,例如:

ssh -L 8443:internal.example.com:443 bastion.example.com
curl --resolve internal.example.com:8443:127.0.0.1 \
  https://internal.example.com:8443/

这里 SSH 只负责建立端口转发,内层仍然是 TLS。证书必须匹配 internal.example.com,不能因为外层 SSH 已认证跳板机,就跳过内层 TLS 验证。


十七、一个可重复的本地测试流程

以下流程使用自签名 CA 和本地 HTTPS 服务,适合验证链和主机名逻辑,不适合直接作为公网证书方案。

生成测试根 CA:

umask 077

openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out test-ca.key

openssl req \
  -x509 \
  -new \
  -key test-ca.key \
  -sha256 \
  -days 3650 \
  -out test-ca.crt \
  -subj "/CN=WR Test Root CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

生成服务端密钥和 CSR:

openssl genpkey \
  -algorithm RSA \
  -pkeyopt rsa_keygen_bits:2048 \
  -out test-server.key

openssl req \
  -new \
  -key test-server.key \
  -out test-server.csr \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

签发测试证书:

openssl x509 \
  -req \
  -in test-server.csr \
  -CA test-ca.crt \
  -CAkey test-ca.key \
  -CAcreateserial \
  -out test-server.crt \
  -days 30 \
  -sha256 \
  -copy_extensions copy

验证:

openssl verify \
  -CAfile test-ca.crt \
  -verify_hostname localhost \
  test-server.crt

预期:

test-server.crt: OK

启动一个临时 HTTPS 服务:

openssl s_server \
  -accept 8443 \
  -cert test-server.crt \
  -key test-server.key \
  -www

另一个终端访问:

curl \
  --cacert test-ca.crt \
  https://localhost:8443/

如果改为访问:

curl \
  --cacert test-ca.crt \
  https://127.0.0.1:8443/

证书中已经包含 IP:127.0.0.1,因此也应通过主机名验证。若证书只包含 DNS:localhost,访问 IP 则会失败。

这个实验展示了三个独立条件:

test-ca.crt 被客户端信任
    ↓
签名链验证通过

SAN 包含 localhost 或 127.0.0.1
    ↓
主机名验证通过

证书仍在 30 天有效期内
    ↓
时间验证通过

缺少任意一项,连接都可能失败。


十八、运维检查应验证“线上事实”

证书运维不能只检查配置文件。至少应分别验证:

文件层

openssl x509 -in /path/server.crt -noout -dates -subject -issuer
openssl pkey -in /path/server.key -check -noout

配置层

sudo nginx -t
sudo nginx -T

进程层

sudo systemctl is-active nginx
sudo journalctl -u nginx -b --no-pager

网络层

curl -v https://example.com/
openssl s_client -connect example.com:443 \
  -servername example.com \
  -showcerts </dev/null

多节点层

从不同网络入口、IPv4 和 IPv6 分别查询:

curl -4 -sS https://example.com/ >/dev/null
curl -6 -sS https://example.com/ >/dev/null

并比较证书序列号:

for ip in 203.0.113.10 203.0.113.11; do
  echo "== $ip =="
  echo | openssl s_client \
    -connect "$ip:443" \
    -servername example.com 2>/dev/null |
  openssl x509 -noout -serial -dates
done

如果不同节点序列号不一致,证书轮换尚未完成,或者流量仍会命中旧节点。


十九、把证书当作有状态资源管理

证书不是一个静态文件,而是包含多个状态的资源:

未签发
  ↓
已签发但未部署
  ↓
已部署但未 reload
  ↓
已被部分节点使用
  ↓
所有入口已使用
  ↓
接近过期
  ↓
已续期
  ↓
旧证书撤下或吊销

每次状态转换都应有可验证条件。例如:

“已部署”

不能只表示文件存在,而应至少表示:

  • 文件内容为预期序列号;
  • 私钥与证书匹配;
  • 服务配置检查通过;
  • 进程成功 reload;
  • 新 TLS 连接返回新证书。

“续期成功”也不能只看 ACME 客户端退出码。还要验证线上入口、链、SNI、OCSP stapling 和多节点一致性。

TLS 证书排障的核心顺序可以概括为:

先确认连到了哪台机器
再确认服务端选择了哪张证书
再确认链是否完整
再确认主机名和用途
再确认时间与吊销状态
最后检查应用层和代理层

按照这个顺序,证书过期、链缺失、SNI 错配、OCSP 失败、权限错误和续期未加载等问题,都可以从“一个模糊的 HTTPS 报错”还原为明确的数据流和故障状态。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。