私有 CA 实战:短周期证书签发与 mTLS 配置

私有 CA 的价值不在「省一张证书的钱」,而在「建立一套可吊销、可区分用途的内部信任体系」。大量内网服务至今仍在用 openssl req -x509 生成的自签证书:每台机器一张,没有中间层,无法批量吊销,服务端证书和客户端证书没有区别,一旦私钥泄露只能逐台替换。本文用根 CA + 中间 CA 的两级结构重建内部 PKI,把证书有效期压到 30 天,用自动化轮换替代吊销依赖,并在 Nginx 上落地 mTLS 双向认证。

适用场景

  • 内部服务之间需要 mTLS 双向认证,但不想为每个内网域名单独购买公网证书。
  • 现有自签证书泛滥,CA 私钥散落在各台服务器上,无法统一吊销。
  • IoT 设备、API 网关、数据库链路需要以「证书」而非「口令」作为身份凭据。
  • 合规要求「密钥生命周期可管理、证书吊销可追溯」。

前置条件

  • OpenSSL 3.0 及以上(1.1.1 亦可,但 3.x 的 -addext 与 provider 更省事)。
  • 一台离线或严格隔离的机器用于存放根 CA 私钥,禁止联网。
  • 一台可联网的签发机存放中间 CA,供日常签发。
  • 服务端与客户端的时间已同步,证书校验对时间偏差敏感。
  • 目录规划已确定:ca/root、ca/intermediate、ca/issued、ca/crl。

原理说明

信任链的本质是「用上一级的私钥对下一级的公钥与属性签名」。浏览器或服务端只信任根证书,中间证书负责日常签发,这样根私钥可以长期离线,泄露风险与签发频率解耦。中间 CA 的证书里必须写 basicConstraints critical, CA:TRUE, pathlen:0,pathlen:0 表示它只能签发终端证书、不能再签发下级 CA,这条约束能防止中间 CA 被滥用后无限延伸信任链。

终端证书靠 Extended Key Usage 区分用途:serverAuth 只能做服务端,clientAuth 只能做客户端。缺少 EKU 的证书在两个方向都能用,等于把「服务端私钥泄露」升级成「可冒充任意客户端」。主机名必须写在 SAN 里,CN 从 Chrome 58 起已不再参与主机名校验。

短周期(7 至 30 天)是对「吊销不可靠」的工程妥协。CRL 有发布延迟,OCSP 有可用性问题,客户端还可能缓存旧状态;把有效期压短,证书过期本身就是最可靠的失效手段。代价是必须自动化轮换,手工续期在 30 天周期下必然出错。

操作步骤

第一步:生成根 CA(离线机执行)

根证书有效期 20 年,密钥 4096 位 RSA 或 P-384。-addext 直接写入关键扩展,避免后续手改配置文件出错。

mkdir -p ~/ca/root/{certs,private,newcerts,crl}
cd ~/ca/root
chmod 700 private
touch index.txt
echo 1000 > serial
echo 1000 > crlnumber

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 \
  -aes-256-cbc -out private/root-ca.key

openssl req -new -x509 -days 7300 -sha384 \
  -key private/root-ca.key -out certs/root-ca.crt \
  -subj "/C=CN/O=Example Internal/CN=Example Root CA R1" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash"

openssl x509 -in certs/root-ca.crt -noout -text | \
  grep -A2 "Basic Constraints"

pathlen:1 允许根 CA 签发一级中间 CA,但不能更深。keyUsage 必须含 keyCertSign 与 cRLSign,缺任意一项,签出的证书在严格校验下会报「invalid CA certificate」。

第二步:生成中间 CA(签发机执行)

中间 CA 有效期建议 5 年,短于根证书,便于定期轮换。CSR 在签发机生成,只把 CSR 交给离线机签名。

mkdir -p ~/ca/intermediate/{certs,private,newcerts,crl}
cd ~/ca/intermediate
chmod 700 private
touch index.txt
echo 2000 > serial
echo 2000 > crlnumber

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 \
  -aes-256-cbc -out private/intermediate.key

openssl req -new -sha384 -key private/intermediate.key \
  -out intermediate.csr \
  -subj "/C=CN/O=Example Internal/CN=Example Issuing CA R1"

# ---- 以下在离线根 CA 上执行 ----
openssl x509 -req -in intermediate.csr -sha384 -days 1825 \
  -CA certs/root-ca.crt -CAkey private/root-ca.key -CAcreateserial \
  -out intermediate.crt \
  -extfile <(printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign,digitalSignature\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always")

把 intermediate.crt 拷回签发机,拼出证书链文件,后续 Nginx 与客户端都需要它:

cd ~/ca/intermediate
cp intermediate.crt certs/
cat certs/intermediate.crt ~/ca/root/certs/root-ca.crt > certs/ca-chain.crt
openssl verify -CAfile ~/ca/root/certs/root-ca.crt certs/intermediate.crt

第三步:配置签发模板

用一份配置文件管理签发策略,把 SAN、EKU、有效期长度都固化下来,避免每次命令行临时拼参数。copy_extensions = copy 让 CSR 里的 SAN 直接进入证书。

# ~/ca/intermediate/openssl.cnf
[ ca ]
default_ca = CA_default

[ CA_default ]
dir               = /home/pki/ca/intermediate
database          = $dir/index.txt
new_certs_dir     = $dir/newcerts
certificate       = $dir/certs/intermediate.crt
serial            = $dir/serial
crlnumber         = $dir/crlnumber
private_key       = $dir/private/intermediate.key
default_md        = sha256
default_days      = 30
default_crl_days  = 7
policy            = policy_loose
copy_extensions   = copy
unique_subject    = no

[ policy_loose ]
commonName             = supplied
organizationalUnitName = optional
countryName            = optional

[ server_ext ]
basicConstraints = CA:FALSE
keyUsage         = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName   = @alt_names
authorityInfoAccess = OCSP;URI:http://ocsp.internal.example.com

[ alt_names ]
DNS.1 = svc.internal.example.com

[ client_ext ]
basicConstraints = CA:FALSE
keyUsage         = critical,digitalSignature
extendedKeyUsage = clientAuth
subjectAltName   = DNS:client.internal.example.com

第四步:签发服务器与客户端证书

服务器证书 30 天,客户端证书同样 30 天但用途隔离。签发时显式传入 -extensions,不要依赖默认段。

cd ~/ca/intermediate

# 服务器证书
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private/svc.key
openssl req -new -key private/svc.key -out svc.csr \
  -subj "/C=CN/O=Example Internal/CN=svc.internal.example.com" \
  -addext "subjectAltName=DNS:svc.internal.example.com,DNS:svc"

openssl ca -config openssl.cnf -batch -notext -days 30 \
  -extensions server_ext -extfile openssl.cnf \
  -in svc.csr -out certs/svc.crt

# 客户端证书(同一 CA,不同 EKU)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private/client01.key
openssl req -new -key private/client01.key -out client01.csr \
  -subj "/C=CN/O=Example Internal/OU=Service/CN=client01"

openssl ca -config openssl.cnf -batch -notext -days 30 \
  -extensions client_ext -in client01.csr -out certs/client01.crt

# 检查 EKU 是否写对
openssl x509 -in certs/svc.crt -noout -text | grep -A1 "Extended Key Usage"
openssl x509 -in certs/client01.crt -noout -text | grep -A1 "Extended Key Usage"

注意 openssl ca 在 copy_extensions = copy 与显式 -extfile 同时存在时,以 -extfile 中的扩展段为准,所以 alt_names 段里的域名要按服务逐个改,或改用 CSR 里的 SAN 由 copy 带入。

第五步:Nginx 启用 mTLS

核心三行:ssl_client_certificate 指定信任链、ssl_verify_client on 强制校验、ssl_verify_depth 2 允许「根 → 中间 → 终端」两级链。

server {
    listen 443 ssl;
    server_name svc.internal.example.com;

    ssl_certificate     /etc/nginx/certs/svc-chain.crt;
    ssl_certificate_key /etc/nginx/certs/svc.key;

    # 客户端证书校验
    ssl_client_certificate /etc/nginx/certs/ca-chain.crt;
    ssl_verify_client      on;
    ssl_verify_depth       2;
    ssl_crl                /etc/nginx/certs/intermediate.crl;

    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;

    location / {
        # 把客户端身份传给后端
        proxy_set_header X-Client-Verify  $ssl_client_verify;
        proxy_set_header X-Client-DN      $ssl_client_s_dn;
        proxy_set_header X-Client-Serial  $ssl_client_serial;
        proxy_pass http://127.0.0.1:8080;
    }
}

服务器证书文件必须是「终端证书 + 中间证书」的拼接顺序,顺序反了部分客户端会报「unable to get local issuer certificate」。ssl_crl 缺失时会静默跳过吊销检查,这一点在排障时极易误判。

第六步:吊销与 CRL 发布

cd ~/ca/intermediate

# 吊销客户端证书
openssl ca -config openssl.cnf -revoke certs/client01.crt -crl_reason keyCompromise

# 重新生成 CRL(PEM 格式,默认有效期 7 天)
openssl ca -config openssl.cnf -gencrl -out crl/intermediate.crl.pem
openssl crl -in crl/intermediate.crl.pem -noout -text | head -20

# 分发到 Nginx 并热加载
sudo cp crl/intermediate.crl.pem /etc/nginx/certs/intermediate.crl
sudo nginx -t && sudo nginx -s reload

CRL 有效期只有 7 天,必须配定时任务重新生成并分发,否则过期后 Nginx 会拒绝所有连接。可以用 ssl_stapling 之外的 OCSP 方案替代,但内部 PKI 场景下 CRL + 短周期已经够用。

配置验证

# 1) 校验证书链与 EKU
openssl verify -CAfile ~/ca/root/certs/root-ca.crt \
  -untrusted ~/ca/intermediate/certs/intermediate.crt certs/svc.crt

# 2) 不带客户端证书,应被拒绝
curl -sv https://svc.internal.example.com/ --cacert root-ca.crt 2>&1 | grep -i "alert\|certificate required"

# 3) 带客户端证书,应返回 200
curl -s -o /dev/null -w "%{http_code}\n" \
  --cert client01.crt --key client01.key --cacert root-ca.crt \
  https://svc.internal.example.com/

# 4) 用错误 EKU 的证书访问,应握手失败
openssl s_client -connect svc.internal.example.com:443 \
  -cert svc.crt -key svc.key -CAfile root-ca.crt < /dev/null | grep -i "verify return code"
  • 第 1 条返回 OK 说明链与扩展都正确;返回 unable to get local issuer 通常是中间证书没带上。
  • 第 2 条应看到 certificate required 或 TLS alert,说明 ssl_verify_client on 生效。
  • 第 4 条用 serverAuth 证书当客户端,应报 unsupported certificate purpose,这是 EKU 隔离生效的直接证据。

常见问题 FAQ

FAQ 1:根 CA 私钥放在服务器磁盘上够安全吗?必须上 HSM 吗?

不够,但也不必一步到位买 HSM。最小可行方案是:根私钥存放在离线机器,加密保护(-aes-256-cbc),备份到两张离线介质并异地存放,签名操作按需启封、用完立即断开网络。中间 CA 私钥放在签发机,权限 0600、属主专用账号,禁止 sudo 之外的读取。当日签发量超过几百张、或有合规硬性要求时,再引入 HSM 或云 KMS 托管私钥,此时签发脚本改成调用 PKCS#11 接口即可,证书模板不用变。

FAQ 2:证书只有 30 天有效期,是不是可以不做 CRL?

不能完全省。短周期降低的是「泄露后长期可用」的风险,但私钥在有效期内泄露仍然要能立即失效,尤其是 clientAuth 证书被盗用后,攻击者可以在 30 天内持续调用接口。CRL 的作用是提供「分钟级失效」能力,与短周期是互补关系而不是替代关系。实践口径:证书有效期 30 天、CRL 有效期 7 天、CRL 每 6 小时重新生成一次,同时把关键服务的私钥访问纳入审计。

FAQ 3:内网服务用私有 CA 签的证书,浏览器一直提示不安全怎么办?

浏览器不信任私有 CA 是设计使然,不能通过配置绕过。三种处理方式:面向员工的 Web 系统改用公网 CA 签发的证书(内网域名可用 DNS-01 验证),私有 CA 只用于服务间 mTLS 与设备身份;或者把根证书通过组策略/MDM 下发到受管终端的信任存储,此时内部域名可以正常显示安全;对于不面向浏览器的场景(API、数据库、消息队列),根本不需要浏览器信任,直接用私有 CA 即可。要避免的是「为了消警告而关闭校验」,那等于把 mTLS 退化成明文。

总结

私有 CA 的工程重点是三件事:结构上分两级,让根私钥离线;扩展上写死 EKU 与 SAN,让证书用途不可混用;运维上把有效期压短并自动化轮换,让「过期」成为比「吊销」更可靠的失效手段。落地顺序建议先建根与中间 CA、跑通一张服务器证书和一张客户端证书的签发与校验,再接 Nginx mTLS,最后补 CRL 与轮换脚本。每一步都用 openssl verify 和 s_client 验证,不要等到业务报错才发现链没拼对。