Ubuntu 24.04 自簽憑證正確設定:Chrome 可接受
在 Ubuntu 24.04 使用一個含 SAN 的 openssl 指令建立 Chrome 可接受的 TLS 憑證,接入 nginx 或 Apache,並正確設定信任,不必使用 curl -k。
建構內容
建立一張現代瀏覽器與用戶端確實會接受的自簽 TLS 憑證,設定正確的 subjectAltName、妥善的金鑰權限,並將憑證套用至 nginx 或 Apache。此外,還會處理幾乎所有教學都略過的部分:讓用戶端正確信任這張憑證,而不是永遠略過警告,或在指令碼中硬編碼 curl -k。最後也會介紹一組 5 個指令即可建立的私有 CA,適用於內部服務從 1 個增加到 6 個的情況。
先做選擇,因為自簽憑證適用的情境,遠比實際使用它的情況少。如果服務可透過真實 DNS 名稱從公開網際網路連線,請停止閱讀,改用 在 nginx 上使用 certbot 申請免費的 Let's Encrypt 憑證,或使用 Apache 的對應作法。這不需要費用,會自動續期,而且主流瀏覽器與作業系統已經信任它。在公開網站上使用自簽憑證,會讓使用者養成略過安全性警告的習慣;這比使用純 HTTP 更糟。
當不涉及公開網際網路時,自簽憑證才是合適的工具:繫結至 VPS 上 WireGuard 通道位址的管理面板、私有網路中的 staging 主機、後端服務之間的服務對服務流量、家用實驗室設備,或替換 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 在 2017 年 4 月的版本 58 起停止讀取 Common Name;早在 2000 年,RFC 2818 就已淘汰以 CN 進行比對,Firefox 和 Safari 的行為也相同。若憑證完全沒有 SAN,curl 和 Python 的 ssl 模組仍會回退使用 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,效期為 2 年;該數字的詳細說明請見效期一節。-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 package 時,才會變成 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 的 mode 600 對它們可行。如果金鑰供以自身使用者身分執行、並自行載入金鑰的服務使用,例如 Node 應用程式、Gitea 或 Python daemon,則將 chown 改為該服務使用者,並維持 mode 600。絕對不要使用 mode 644、將副本放在 git repository 中,或將副本放在 /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 在這裡並不足夠:以下 vhost 會使用 ProxyPass,若缺少 mod_proxy 與 mod_proxy_http,設定檔測試會因 Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration 而失敗。將 vhost 儲存為 /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。自 version 146 起,這是 Chrome 的預設路徑;如果使用者仍保留舊版安裝留下的~/.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,並將其安裝到「Trusted Root Certification Authorities」;在 macOS 上,使用 Keychain Access 將其加入 System keychain,並標記為「Always Trust」。
多個服務共用一個根憑證:建立小型私有 CA
逐一信任憑證很快就無法擴充:6 個服務乘上 4 台用戶端機器,就需要安裝 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 的主機上,因此非常適合開發筆電,卻不適合伺服器叢集。
截至 October 2026,限制在於:mkcert 只會在舊版 ~/.pki/nssdb 路徑尋找 Chrome 的 NSS 資料庫,不會搜尋 Chrome 自 version 146 起預設使用的 ~/.local/share/pki/nssdb。在沒有舊版資料庫的機器上,請使用上一節的 certutil 指令,並搭配 -i "$(mkcert -CAROOT)/rootCA.pem",手動為 Chrome 加入根憑證。
伺服器則可使用純 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/Browser Forum 已在 2026 年 3 月將新簽發的公開信任憑證上限設為 200 天,低於原本的 398 天;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 起,除非存在舊版 ~/.pki/nssdb,否則該資料庫位於 ~/.local/share/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):憑證與金鑰不相符,通常是因為生成命令執行了 2 次,導致檔案混用。請比較 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=..." 重新簽發。
如何讓 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 裝置不論簽發者為何,都會拒絕有效期更長的憑證。使用有效期 10 年的私有根憑證,搭配有效期 2 年的(-days 730)葉憑證,是合理的預設做法。請排定續期日程,因為內部憑證過期後,可能在沒有人記得的日期讓所有服務無聲中斷。
應該使用自簽憑證還是 Let's Encrypt?
如果服務具有公開 DNS 名稱,且可從網際網路連線,請一律使用 Let's Encrypt。它免費、自動化,且大多數瀏覽器與作業系統已預先信任(如果必須支援非常舊的裝置,請查看 Let's Encrypt 的相容性清單)。自簽憑證(或私有 CA)適用於 Let's Encrypt 無法簽發憑證的情況:私有 IP、僅供內部使用的主機名稱(例如 .lan)、隔離網路,以及刻意隱藏在 VPN 後方的服務。決策取決於可連線性與命名方式,而不是安全強度;兩者使用的密碼編譯技術相同。
為什麼將憑證加入 /usr/local/share/ca-certificates 後仍遭拒絕?
請檢查 3 件事。檔案結尾必須是 .crt;使用 .pem 副檔名的檔案會遭靜默略過,而 update-ca-certificates 會回報 0 added。內容必須是以 -----BEGIN CERTIFICATE----- 開頭的 PEM 文字,而不是 DER 二進位資料。此外,應用程式必須確實使用系統存放區。Linux 上的 Chrome、Firefox、Python requests、Node.js(除非使用 --use-system-ca 啟動)及 Java 各自維護私有信任存放區,因此必須分別加入該憑證。