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 端口自行生成的占位证书。无论是 10.8.0.1 还是 git.internal.lan,Let's Encrypt 都无法为其签发证书;公共 CA 不会将私有 IP 或虚构的顶级域名写入证书。对于这些名称,您就是 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 在 2017 年 4 月发布的 58 版本中停止读取 Common Name;早在 2000 年,RFC 2818 就已弃用基于 CN 的匹配。Firefox、Safari、curl 和 Python 的行为也相同。证书必须通过 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 中,/etc/ssl/private 已经是 710 root:ssl-cert,可以阻止普通用户随意查看,但仍应明确设置文件本身的权限:
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/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt。 - Firefox 使用自己的证书存储:依次选择 Settings → Privacy & Security → Certificates → Import,或者在
about:config中将security.enterprise_roots.enabled修改为true,使其读取系统证书存储。 - 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。
在 Windows 客户端上,双击 .crt,并将其安装到 Trusted Root Certification Authorities;在 macOS 上,使用 Keychain Access 将其添加到 System keychain,并标记为 Always Trust。
多个服务共用一个根证书:小型私有 CA
按证书分别建立信任关系很快就无法扩展:六个服务乘以四台客户端计算机,需要安装二十四次信任证书;每增加一个服务,还会增加安装次数。解决方法是使用私有 CA,让客户端只信任一个根证书,再使用该根证书为每个服务签发证书。
便捷的选择是 mkcert。它位于 Ubuntu 24.04 的软件仓库中,并能处理 update-ca-certificates 未覆盖的 NSS 存储区(Chrome、Firefox):
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install 会创建根证书,并将其注册到该计算机上的每个信任存储区;第三条命令会生成 git.internal.lan+2.pem 和 git.internal.lan+2-key.pem,可直接放入上文的 nginx 或 Apache 配置片段中。它的设计前提是开发计算机:根密钥保存在运行 -install 的计算机上。因此,它非常适合开发笔记本电脑,但不适合服务器集群。
对于服务器,直接使用 OpenSSL,只需五条命令即可完成整个 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 步骤);或者复制的文件没有以 .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 数据库,而不是系统存储。如果错误是 NET::ERR_CERT_COMMON_NAME_INVALID,证书缺少与 URL 匹配的 Subject Alternative Name,必须使用 -addext "subjectAltName=..." 重新签发。
如何让 curl 信任自签名证书而不使用 -k?
将证书(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 days,2029 年为 47 days)约束的是受公众信任的 CA,不适用于私有信任体系。实际上,服务器证书的有效期应限制为 825 days,因为无论签发者是谁,Apple 设备都会拒绝更长的证书。使用有效期为十年的私有根证书,以及有效期为两年的(-days 730)叶证书,是合理的默认设置。请安排续期日历提醒,因为内部证书过期后,可能在无人记得的日期使所有服务静默中断。
应该使用自签名证书还是 Let's Encrypt?
如果服务具有公网 DNS 名称,并且可从互联网访问,应始终使用 Let's Encrypt。它免费、自动化,并且已受到所有客户端信任。Let's Encrypt 无法签发证书的场景,才适合使用自签名证书(或私有 CA),包括私有 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 和 Java 各自维护私有信任存储,需要分别添加证书。