Ubuntu 24.04如何生成Chrome接受的自签名证书
在 Ubuntu 24.04 上用一条 OpenSSL 命令生成包含 SAN 的证书,配置 nginx 或 Apache,并正确导入信任存储,无需使用 curl -k。
构建内容
生成一个现代浏览器和客户端可以实际接受的自签名 TLS 证书,正确设置 subjectAltName、合理配置密钥权限,并将其接入 nginx 或 Apache。此外,还要完成几乎所有指南都会跳过的一步:正确让客户端信任该证书,而不是让用户忽略警告,或永久将 curl -k 硬编码到脚本中。最后,还会介绍一个包含 5 条命令的私有 CA,用于内部服务从 1 个增加到 6 个的情况。
首先需要做出选择,因为自签名证书远没有实际使用得那么频繁。如果服务通过真实 DNS 名称对公网提供访问,请停止阅读,改用 在 nginx 上使用 certbot 获取免费的 Let's Encrypt 证书,或使用 Apache 的对应方案。该证书不收取费用,可以自动续期,主流浏览器和操作系统也已经信任它。在公网网站上使用自签名证书,会让用户养成忽略安全警告的习惯,这比使用纯 HTTP 更糟糕。
在以下情况下,自签名证书才是合适的选择:服务不涉及公网,例如绑定到 VPS 上 WireGuard 隧道地址的管理面板、私有网络中的预发布服务器、后端之间的服务间流量、家庭实验室设备,或替换 Webmin 在 10000 端口自行生成的占位证书。Let's Encrypt 无法为 10.8.0.1 或 git.internal.lan 签发证书,任何公共 CA 也不会将私有 IP 或虚构的 TLD 写入证书。对于这些名称,您就是 CA。
以下所有操作都在全新的 Ubuntu 24.04 服务器上执行。该版本随附 OpenSSL 3.0.x(openssl version 用于确认)。这里的所有步骤都不需要互联网访问,在完全隔离的网络中也能运行。
为什么旧的一行命令生成的证书会被 Chrome 拒绝
每篇 2017 年以前的教程都会让你运行类似下面的命令:
# Do not run this: shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout selfsigned.key -out selfsigned.crt该命令会依次询问一系列交互式问题,将主机名写入 Common Name 字段,并生成不包含 subjectAltName 扩展的证书。此类证书生成后就无法使用。Chrome 在 58 版中停止读取 Common Name,这一改动发生在 2017 年 4 月。早在 2000 年,RFC 2818 就已弃用 CN 匹配;Firefox 和 Safari 的行为也相同。curl 和 Python 的 ssl 模块仍会在证书完全没有 SAN 时回退到 Common Name,因此不含 SAN 的证书可能通过快速的 curl 测试,却仍会在所有浏览器中失败。对于浏览器而言,证书必须通过 SAN 扩展标识其服务器,否则就不包含服务器身份信息。浏览器会明确显示以下错误:
NET::ERR_CERT_COMMON_NAME_INVALID
This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.反复调整信任存储也无法修复此错误,因为证书确实没有标识任何名称。如果你现在看到 NET::ERR_CERT_COMMON_NAME_INVALID,说明证书没有 SAN,或 SAN 不正确,需要重新签发证书。修复方法只需运行一条命令。
签发浏览器接受的证书:一条命令
OpenSSL 在 1.1.1 中新增了 -addext 标志,因此不再需要旧指南用于注入 SAN 的复杂配置文件操作。在 Ubuntu 24.04 上执行:
sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
-keyout /etc/ssl/private/git.internal.key \
-out /etc/ssl/certs/git.internal.crt \
-subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"各标志的作用如下:
-x509直接生成自签名证书,而不是签名请求。-newkey rsa:4096在同一步生成新密钥。RSA 4096 不会影响旧客户端;如果所有连接方都使用现代软件,-newkey ec -pkeyopt ec_paramgen_curve:P-256更小、更快。-noenc是 OpenSSL 3.x 对旧版-nodes的写法:密钥不使用密码短语。两种写法都有效。使用密码短语的密钥会导致 nginx 在每次启动时等待输入,因此服务器密钥应使用此选项。-days 730,有效期两年;该数值的更多说明见有效期部分。-subj将交互式问题直接写入命令。CN 现在只是显示用途,但仍应将其设置为主名称;某些工具会显示它。-addext "subjectAltName=..."是关键标志。列出客户端会输入的每个名称和每个 IP:主机名使用DNS:条目(可使用DNS:*.internal.lan等通配符),地址使用IP:条目。如果有人会访问https://10.8.0.1,就必须包含IP:10.8.0.1条目;只配置 DNS SAN 会让他们再次遇到NET::ERR_CERT_COMMON_NAME_INVALID。
在配置其他内容前,先确认 SAN 确实已写入证书:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltName正确输出:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1如果输出的是 No extensions in certificate,说明证书没有 SAN。浏览器会拒绝该证书。请重新生成证书,不要继续配置。
锁定密钥
所有用户都可读取的私钥不是真正的私钥。在原版 Ubuntu 24.04 系统中,/etc/ssl/private 已经是 700 root:root,因此只有 root 甚至可以列出它;只有在某个组件拉取了 ssl-cert 软件包后,它才会变为 710 root:ssl-cert(apache2 会推荐安装该软件包)。无论哪种情况,都应明确设置文件本身的权限:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.keynginx 和 Apache 都会先以 root 身份读取证书,然后再降权运行,因此使用 root:root 模式 600 即可。如果密钥属于以自身用户身份运行并自行加载密钥的服务,例如 Node 应用、Gitea 或 Python 守护进程,chown 应将文件所有者改为该服务用户,同时仍使用模式 600。以下做法绝对不要使用:模式 644、将副本放入 git 仓库,或将副本放入 /tmp。
将其接入 nginx
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name git.internal.lan;
ssl_certificate /etc/ssl/certs/git.internal.crt;
ssl_certificate_key /etc/ssl/private/git.internal.key;
location / {
proxy_pass http://127.0.0.1:3000;
}
}sudo nginx -t && sudo systemctl reload nginxnginx -t 必须在重新加载执行任何操作前输出 syntax is ok 和 test is successful。如果输出的是 SSL_CTX_use_PrivateKey_file() failed ... key values mismatch,则证书和密钥来自两次不同的生成过程,请参阅故障模式部分。
接入 Apache
sudo a2enmod ssl proxy proxy_http仅有 ssl 在这里还不够:下面的虚拟主机配置使用了 ProxyPass,如果缺少 mod_proxy 和 mod_proxy_http,配置测试会因 Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration 失败。将虚拟主机配置保存为 /etc/apache2/sites-available/git-internal.conf:
<VirtualHost *:443>
ServerName git.internal.lan
SSLEngine on
SSLCertificateFile /etc/ssl/certs/git.internal.crt
SSLCertificateKeyFile /etc/ssl/private/git.internal.key
ProxyPass / http://127.0.0.1:3000/
ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2configtest 应响应 Syntax OK。现在从客户端计算机进行测试:
curl -v https://git.internal.lan/此时会收到错误:
curl: (60) SSL certificate problem: self-signed certificate这不是错误。TLS 正在正常工作:curl 从未见过您的证书,因此拒绝与无法验证身份的服务器通信。下一节将介绍真正的解决方法,而不是照搬当前互联网上大量示例的做法。
让客户端信任证书,以及应拒绝的反模式
先说明错误的修复方式,并明确指出它们的问题。将 curl -k(或 --insecure)写入脚本、在 Python requests 中使用 verify=False、在 Node 中使用 NODE_TLS_REJECT_UNAUTHORIZED=0,都不会让客户端信任您的证书。它们只是关闭证书验证,这意味着客户端会与提供任意证书的任何服务器通信,包括攻击者插入通信路径的服务器。您仍然承担 TLS 的开销,却失去了 TLS 原本用于提供的身份验证。更糟的是,这些标志会不断扩散:先粘贴到一个 cron 任务中,再复制到部署脚本,最后进入生产代码,直到没人记得哪些连接原本只是临时使用。如果一个 verify=False 在引入它的调试会话结束后仍然存在,说明设计有问题。
正确的修复方式是将此证书添加为受信任根证书,让每个客户端操作系统都信任它。在 Ubuntu 和 Debian 客户端上:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificates输出中需要关注的行如下(其后会出现一个 Running hooks in /etc/ca-certificates/update.d... 块):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.这些命令中有两个容易忽略的问题。文件名必须以 .crt 结尾;如果使用 .pem 扩展名,文件会被静默忽略,并且在没有错误消息的情况下得到 0 added。文件内容必须采用 PEM 格式,文件以 -----BEGIN CERTIFICATE----- 开头;如果原文件是 DER 二进制格式,先使用 openssl x509 -inform der -in file.der -out file.crt 转换。将自签名证书本身添加为根证书是可行的,因为自签名证书本身就是其根证书。
完成后,curl、wget、git、apt 以及其他所有使用 OpenSSL 并读取系统证书包的程序,都无需任何标志即可信任该服务器。少数客户端使用自己的信任存储,需要单独处理:
- Linux 上的 Chrome/Chromium 读取 NSS 数据库,而不是系统证书存储:
sudo apt install libnss3-tools,然后为每个用户执行certutil -d sql:$HOME/.local/share/pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt。这是 Chrome 自 version 146 起的默认路径;如果用户仍保留旧安装生成的~/.pki/nssdb,Chrome 会继续读取该文件,因此在这些计算机上将-d指向sql:$HOME/.pki/nssdb。 - Firefox 使用自己的证书存储:设置 → 隐私与安全 → 证书 → 导入。在 Windows 和 macOS 上,也可以在
about:config中将security.enterprise_roots.enabled改为true,让 Firefox 读取操作系统证书存储。Mozilla 仅记录了 Windows 和 macOS 对该首选项的支持,未记录 Linux;因此在 Ubuntu 上请使用导入功能。 - Python requests 自带 CA 证书包(certifi),并忽略系统证书存储:传递
verify="/usr/local/share/ca-certificates/git.internal.crt"或导出REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt。 - Node.js 默认使用自带的根证书:导出
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt;或者使用--use-system-ca标志让它读取刚更新的系统证书存储(Linux 上的 Node 23.9 及更高版本),也可以使用NODE_USE_SYSTEM_CA=1(Node 24.6 以及 Node 22.19 及更高版本)。
在 Windows 客户端上,双击 .crt,然后将其安装到“受信任的根证书颁发机构”;在 macOS 上,使用“钥匙串访问”将其添加到“系统”钥匙串,并将其标记为“始终信任”。
一个根证书覆盖多个服务:小型私有 CA
为每个证书单独配置信任很快就无法扩展:六个服务乘以四台客户端计算机,就是 24 次信任安装;每增加一个服务,还要增加更多安装。解决方案是使用私有 CA,让客户端只信任一个根证书,再用它为每个服务签发证书。
更方便的选择是 mkcert。它位于 Ubuntu 24.04 的软件仓库中,并能处理 update-ca-certificates 未覆盖的 NSS 存储区(Firefox,以及存在下文所述一个例外情况的 Chrome):
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install 会创建根证书,并将其注册到系统存储区、它能找到的浏览器 NSS 存储区,以及在设置 JAVA_HOME 时的 Java 存储区。Node 默认不会读取系统存储区,因此请像上文所述那样使用 --use-system-ca(或 NODE_USE_SYSTEM_CA=1)启动 Node 应用,或者导出 NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"。第三条命令会生成 git.internal.lan+2.pem 和 git.internal.lan+2-key.pem,可直接放入上文的 nginx 或 Apache 配置片段中。它的设计前提是开发计算机:根证书私钥保存在运行 -install 的计算机上。因此,它非常适合开发笔记本电脑,但不适合服务器集群。
截至 2026 年 10 月,存在一个限制:mkcert 只会在旧版 ~/.pki/nssdb 路径中查找 Chrome 的 NSS 数据库,不会查找 Chrome 自 146 版本起默认使用的 ~/.local/share/pki/nssdb。在没有旧版数据库的计算机上,请使用上一节中的 certutil 命令为 Chrome 手动添加根证书,并使用 -i "$(mkcert -CAROOT)/rootCA.pem"。
对于服务器,使用原生 OpenSSL,只需 5 条命令即可完成整个 CA 的配置:
openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
-out lab-ca.crt -subj "/CN=Lab Internal CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
-addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
-CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crt问题出在最后一条命令:openssl x509 -req 默认会丢弃 CSR 中的所有扩展,包括你仔细添加的 SAN。-copy_extensions copy 是 OpenSSL 3.x 的选项,因此可在 24.04 上使用;它会保留这些扩展。省略该选项后,签发的证书没有 SAN,Chrome 又会显示 NET::ERR_CERT_COMMON_NAME_INVALID。使用之前相同的 openssl x509 -noout -ext subjectAltName 检查进行验证。
按照上面的信任存储区步骤,将 lab-ca.crt 分发到客户端。每台计算机只需执行一次。像保护最重要的密钥一样保护 lab-ca.key:将权限设为 600,最好保存在不属于其签发目标服务器的另一台计算机上,因为持有该文件的人可以为客户端信任的任何名称签发证书。
有效期与轮换
公共 CA 证书的有效期正在缩短。CA/浏览器论坛已于 2026 年 3 月将新签发的公信证书有效期上限从 398 天降至 200 天,并计划在 2027 年降至 100 天、在 2029 年 3 月降至 47 天。但这些规则只约束公信 CA。您的私有 CA 不受这些规则约束,浏览器也不会对手动安装的根证书强制执行这些限制。实际存在一个适用范围更广的限制:无论证书由谁签发,Apple 平台都会拒绝有效期超过 825 天的 TLS 服务器证书。因此,如果 iPhone 或 Mac 将连接到服务,请将叶证书有效期控制在两年以内。-days 730 在所有环境中都符合该限制;使用十年有效期的根证书和两年有效期的叶证书,是一种稳妥的内部配置。
长期有效的证书只会以一种方式失效:在没有人记得曾选择过的日期,所有证书同时静默失效。先检查现有证书:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate将续期安排到实际的日历中,或者让 cron 在到期前 30 天提醒您。openssl x509 -checkend 2592000 -in cert.crt 会在证书将在指定秒数内到期时以非零状态退出。如果您已经运行 用于状态监控的 Uptime Kuma,其 HTTPS 监控器可以免费标记即将到期的证书。
使用私有 CA 进行轮换非常简单:重新运行 CSR 和签名命令,替换证书文件,然后重新加载 Web 服务器。根证书没有变化,因此客户端不会察觉任何变化。
故障模式及对应提示信息
NET::ERR_CERT_AUTHORITY_INVALID:这是安装信任证书前的预期状态,不表示证书存在缺陷。如果在安装根证书后仍然出现此提示:在 Linux 上,Chrome 使用 NSS,而不是系统证书存储(请参阅 certutil 步骤);从 Chrome 146 开始,该数据库位于 ~/.local/share/pki/nssdb,除非存在旧版 ~/.pki/nssdb,因此添加到另一条路径的根证书不会被发现;或者复制的文件没有以 .crt 结尾,且 update-ca-certificates 显示为 0 added;也可能是服务器提供的证书与您信任的证书不同,请使用 openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256 比较指纹。
NET::ERR_CERT_COMMON_NAME_INVALID:证书没有 SAN,或者 SAN 未覆盖地址栏中的名称。典型情况是:SAN 列出的是 DNS:git.internal.lan,但用户访问的是 https://10.8.0.1。修改信任存储无法解决此问题;请重新签发证书,并加入缺少的条目。
curl: (60) SSL certificate problem: self-signed certificate:curl 不信任该证书。对于由私有 CA 签发的证书,self-signed certificate in certificate chain 表示相同的问题。临时修复方法:curl --cacert lab-ca.crt https://...;永久修复方法:配置信任存储。不要使用 -k。
unable to load certificate ... Expecting: TRUSTED CERTIFICATE(或 Expecting: CERTIFICATE REQUEST、no start line):PEM 类型混淆。您向 OpenSSL 提供了错误类型的文件:在需要证书时提供了密钥或 CSR,或者在需要 PEM 时提供了 DER 二进制文件。head -1 filename 会告诉您实际拥有的文件类型;证书以 -----BEGIN CERTIFICATE----- 开头。对于 DER 文件,请使用 openssl x509 -inform der -in file.der -out file.crt 进行转换。
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch):证书和密钥不匹配,通常是因为生成命令运行了两次,导致文件混用。使用 openssl x509 -in git.internal.crt -noout -pubkey | sha256sum 和 openssl pkey -in git.internal.key -pubout | sha256sum 进行确认;哈希值匹配表示两者属于同一对。如果不同,请同时重新生成证书和密钥。
FAQ
为什么创建自签名证书后,Chrome 仍显示“不安全”?
如果错误是 NET::ERR_CERT_AUTHORITY_INVALID,说明证书本身没有问题,只是 Chrome 尚未信任它。将证书(或您的私有 CA 根证书)安装到客户端的信任存储中。请注意,在 Linux 上,Chrome 通过 certutil 使用 NSS 数据库;自 Chrome 146 起,数据库位于 ~/.local/share/pki/nssdb,如果存在旧版数据库,则位于 ~/.pki/nssdb,而不是使用系统存储。如果错误是 NET::ERR_CERT_COMMON_NAME_INVALID,说明证书缺少与 URL 匹配的 Subject Alternative Name,必须使用 -addext "subjectAltName=..." 重新签发。
不使用 -k,如何让 curl 信任自签名证书?
将证书(PEM 格式,扩展名为 .crt)复制到 /usr/local/share/ca-certificates/,然后运行 sudo update-ca-certificates。输出必须显示 1 added。之后,curl 会像验证公共证书一样验证该证书。若只需执行一次请求且不修改系统,curl --cacert /path/to/cert.crt 只针对该文件进行验证;-k 会完全禁用验证,不应写入任何人的脚本。
自签名证书可以有效多长时间?
从技术上说,期限可以由您自行决定。CA/Browser Forum 的限制(目前为 200 天,到 2029 年为 47 天)约束的是受公众信任的 CA,不适用于私有信任体系。实际上,应将服务器证书的有效期限制为 825 天,因为 Apple 设备会拒绝更长期限的证书,与签发者无关。使用有效期为十年的私有根证书,以及有效期为两年的(-days 730)叶证书,是合理的默认方案。请记录续期日期,因为内部证书过期可能会在无人记得的日期导致所有服务静默中断。
应使用自签名证书还是 Let's Encrypt?
如果服务具有公共 DNS 名称并可从互联网访问,应始终使用 Let's Encrypt。它免费、自动化,并且已受到大多数浏览器和操作系统信任(如果必须支持非常旧的设备,请查看 Let's Encrypt 的兼容性列表)。自签名证书(或私有 CA)适用于 Let's Encrypt 无法签发证书的场景:私有 IP、仅限内部使用的主机名(例如 .lan)、隔离网络,以及有意隐藏在 VPN 后的服务。选择取决于可达性和命名方式,而不是安全强度;两者使用的密码学技术相同。
为什么将证书添加到 /usr/local/share/ca-certificates 后仍被拒绝?
请检查以下三点。文件必须以 .crt 结尾;.pem 扩展名会被静默跳过,而 update-ca-certificates 会报告 0 added。内容必须是以 -----BEGIN CERTIFICATE----- 开头的 PEM 文本,不能是 DER 二进制文件。此外,应用必须实际使用系统存储。Linux 上的 Chrome、Firefox、Python requests、Node.js(除非使用 --use-system-ca 启动)和 Java 都各自维护私有信任存储,需要分别添加该证书。