自签名还是 CA 签发,问题在于由谁为公钥与名称之间的绑定关系担保。自签名证书用自己的密钥签名,因此它只能证明持有者拥有该密钥;只有事先被告知要信任这张特定的证书,客户端才会信任它。CA 签发的证书由证书颁发机构签名,而客户端已经信任该机构的根证书:这个 CA 可以是面向互联网的公共 CA,也可以是由你自己运营并分发的私有 CA。
概览
| 自签名 | 私有 CA | 公共 CA | |
|---|---|---|---|
| 由谁为叶子证书签名 | 叶子证书自己的密钥 | 你的根 CA 或中间 CA | 浏览器和操作系统根证书库中的某个 CA |
| 客户端如何建立信任 | 每个客户端都要固定或安装这张特定证书 | 安装一个根证书(MDM、配置管理、容器镜像) | 默认即受信任 |
| 轮换叶子证书 | 每个客户端都必须更新 | 对客户端透明 | 对客户端透明 |
| 吊销 | 无;只能从每个客户端中移除 | 自己的 CRL、OCSP 或较短的有效期 | 由 CA 发布的 CRL |
| 可包含的名称 | 任意名称 | 根证书约束范围内的任意名称 | 仅限公共域名和公网 IP |
| 有效期上限 | 浏览器不设上限;Apple 平台把2019年7月以来签发的 TLS 证书的有效期限制为最长 825 天 | Apple 平台同样有 825 天上限 | 目前 200 天,2029年3月起 47 天 |
| 证书透明度 | 不记录 | 不记录 | 公开记录,包括每个主机名 |
| 成本与工作量 | 前期没有成本;每次轮换代价高 | 运营 CA、保护其密钥 | 通过 ACME 免费获取;需要 DNS 或 HTTP 验证 |
| 典型工具 | openssl req -x509;本地开发用 mkcert | step-ca、HashiCorp Vault PKI、云服务商的私有 CA、cert-manager | Let's Encrypt 及其他 ACME CA |
客户端如何决定是否信任证书
路径验证(RFC 5280 §6)从服务器证书出发构建一条链,一直延伸到客户端根证书库中的某个信任锚,并在沿途检查每个签名、有效期、基本约束、密钥用法和名称约束。自签名证书的颁发者与主体相同,并由自身的密钥签名,因此唯一可能的链就是这张证书本身。只有当这张证书本身就是客户端的信任锚时,它才能通过验证;所谓“固定”或“安装”自签名证书,指的正是这一点。
CA 签发的叶子证书由中间 CA 签名,而中间 CA 又向上链接到根 CA。客户端从不需要事先知道叶子证书,只需要根证书。正是这一层间接关系,使轮换、吊销和自动化成为可能。
下面的脚本用 OpenSSL 3.6.5 运行,创建一张自签名证书、一个仅限 .internal 下名称的私有根证书,以及两张由该根证书签名的叶子证书。
#!/bin/sh
dir=$(mktemp -d)
cd "$dir" || exit 1
P256="-newkey ec -pkeyopt ec_paramgen_curve:P-256 -noenc"
# 1. Self-signed server certificate
openssl req -x509 $P256 -keyout self.key -out self.pem -days 30 \
-subj "/CN=dev.internal" \
-addext "subjectAltName=DNS:dev.internal,IP:127.0.0.1" \
-addext "basicConstraints=critical,CA:FALSE" 2>/dev/null
openssl verify self.pem
openssl verify -CAfile self.pem self.pem
# 2. Private root CA limited to .internal names
openssl req -x509 $P256 -keyout ca.key -out ca.pem -days 3650 \
-subj "/CN=Example Internal Root" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "nameConstraints=critical,permitted;DNS:.internal" 2>/dev/null
# 3. Two server certificates signed by that CA
for name in api.internal www.example.com; do
openssl req -x509 $P256 -CA ca.pem -CAkey ca.key -keyout "$name.key" \
-out "$name.pem" -days 90 -subj "/CN=$name" \
-addext "subjectAltName=DNS:$name" \
-addext "basicConstraints=critical,CA:FALSE" \
-addext "extendedKeyUsage=serverAuth" 2>/dev/null
done
openssl verify api.internal.pem
openssl verify -CAfile ca.pem api.internal.pem
openssl verify -CAfile ca.pem www.example.com.pem
cd / && rm -rf "$dir"
CN=dev.internal
error 18 at 0 depth lookup: self-signed certificate
error self.pem: verification failed
self.pem: OK
CN=api.internal
error 20 at 0 depth lookup: unable to get local issuer certificate
error api.internal.pem: verification failed
api.internal.pem: OK
CN=www.example.com
error 47 at 0 depth lookup: permitted subtree violation
error www.example.com.pem: verification failed
只有把自签名证书本身作为信任锚传入时,它才能通过验证。私有 CA 签发的叶子证书在根证书受信任后即可通过验证,而根证书的名称约束使该 CA 无法为 www.example.com 担保,一旦 CA 密钥遭到滥用,这就能限制损害范围。注意,openssl req -x509 默认会通过其配置文件把证书标记为 CA;显式设置的 CA:FALSE 可以防止这张自签名服务器证书被用作颁发者。
客户端不信任证书时会报什么错
我们让五个客户端连接一台本地服务器,服务器依次出示上述每种证书,且没有向客户端的信任库添加任何内容(2026年10月20日,macOS)。
| 客户端 | 自签名证书 | 由不受信任的私有 CA 签发的叶子证书 |
|---|---|---|
| Node.js 26.10 | DEPTH_ZERO_SELF_SIGNED_CERT | UNABLE_TO_VERIFY_LEAF_SIGNATURE |
| Python 3.14(OpenSSL 3.6.5) | certificate verify failed: self-signed certificate | certificate verify failed: unable to get local issuer certificate |
| Go 1.25(macOS 验证器) | x509: certificate signed by unknown authority | x509: “localhost” certificate is not trusted |
| curl 8.7.1(LibreSSL) | (60) SSL certificate problem: self signed certificate | (60) SSL certificate problem: unable to get local issuer certificate |
OpenSSL 3.6.5 s_client | Verify return code: 18 (self-signed certificate) | Verify return code: 20 (unable to get local issuer certificate) |
浏览器会显示 NET::ERR_CERT_AUTHORITY_INVALID(Chrome)或 MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT(Firefox)。修复方法绝不是禁用验证(NODE_TLS_REJECT_UNAUTHORIZED=0、verify=False、InsecureSkipVerify、curl -k):这会让该进程发起的每个连接都失去身份验证。正确的做法是把客户端应当信任的证书或根证书交给它(Node.js 中的 ca:、Python 中的 cafile=、Go 中自定义的 RootCAs 池、curl 中的 --cacert),或者把根证书安装到操作系统的信任库中。如果证书的主题备用名称(SAN)扩展中没有 DNS 名称,验证会以另一种方式失败:Chrome 从 58 版(2017年)起不再匹配通用名称,Apple 平台则从 iOS 13 和 macOS 10.15 起不再匹配。
折中方案:私有 CA
私有 CA 保留了自签名的主要好处(无需外部机构、可使用任意内部名称、不会被公开记录),同时消除了它的主要代价(每次轮换都要重新分发信任)。实用规则如下:
- 让根证书保持离线,由中间 CA 签发证书;中间 CA 一旦被攻破,可以直接替换,而无需改动每一个客户端。
- 约束名称,像演示中那样限定在你的内部区域内,并严格限制
pathlen和密钥用法。 - 自动签发叶子证书:step-ca、Vault PKI 和 cert-manager 都能通过 ACME 或自身的 API 签发证书,因此叶子证书的有效期可以只有几小时或几天,吊销也就不再必要。
- 仍然遵守客户端的规则:对于2019年7月1日起签发的 TLS 证书,Apple 平台要求 SAN 中包含 DNS 名称、带有
serverAuth扩展密钥用法、有效期不超过 825 天,即使是私有根证书签发的证书也不例外;398 天的限制只适用于公开受信任的根证书。 - 规划根证书的分发:操作系统信任库、Java 密钥库、容器镜像、自带信任库的浏览器(Firefox),以及自带证书包的软件(Python
requests背后的certifi包;还有 Node.js,除非以--use-system-ca启动,否则它使用内置的 Mozilla 证书包),都需要各自安装根证书。
公共 CA 无能为力的场景
公共 CA 不得签发包含内部名称或保留 IP 地址的证书(《基线要求》§4.2.2)。因此,printer.local 这类名称、单标签主机名、10.0.0.5,以及 .internal 下的任何名称(ICANN 于2024年7月29日将该后缀永久保留供私有使用),都只能使用私有 CA 签发的证书或自签名证书。拥有公共名称的内部主机(例如 db.corp.example.com)可以通过 DNS-01 挑战获取公共证书,而无需能从互联网访问;但这样一来,每个名称都会出现在公开的证书透明度日志中,任何检索这些日志的人都能看出你的内部网络拓扑。
如何选择
- 公共网站、API 和邮件服务器:公共 CA,配合 ACME。
- 内部服务、服务网格、工作负载之间的 mTLS:私有 CA,配合短期叶子证书。
- 本地开发:使用
mkcert这类本地开发 CA,它会把根证书安装到你自己的信任库中;不要使用每个工具都以不同方式拒绝的自签名证书。 - 首次启动的设备和一体机:先使用自签名证书,由用户或预配系统对其指纹验证一次,随后注册到私有 CA。
- 测试夹具:由测试自身生成、并显式传给被测客户端的自签名证书。
- 通过 mTLS 对接的合作伙伴和 B2B 集成:专为这一合作关系设立的私有 CA,切勿使用你通用的内部根证书。
迁移说明
- 清点正在使用的自签名证书:扫描内部端点,收集客户端固定的证书;证书解码器可以显示颁发者、SAN 和到期时间。
- 搭建私有 CA,使用离线根证书和负责签发的中间 CA,并将其约束在你的内部域名范围内;用 CSR 生成器生成证书请求。
- 先分发根证书,再开始切换服务器,按服务逐个替换叶子证书;证书链构建工具可以确认每台服务器都发送了自己的中间证书。
- 客户端信任根证书后,移除不安全的变通做法(
-k、verify=False、InsecureSkipVerify),并搜索代码库,防止它们再次出现。 - 把面向公众的名称迁移到公共 CA,使用 ACME;如果仍需固定,应固定密钥而不是证书;SPKI 固定值生成器计算出的固定值在使用同一密钥续期后依然有效。