SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 已更新 2026-07-24

Ubuntu 24.04 建立自簽章 TLS 憑證教學:解決 Chrome 拒絕連線問題

在 Ubuntu 24.04 使用 OpenSSL 3.0 建立自簽章憑證,並解決 Chrome 因缺少 SAN 擴充功能而顯示錯誤的問題。本文提供正確的指令與 nginx/Apache 設定,教您建立私有 CA 並讓用戶端完全信任憑證,不再需要使用 curl -k 參數。

建立目標

建立一個能被現代瀏覽器與用戶端接受的自簽章 TLS 憑證。包含正確的 subjectAltName、合理的金鑰權限,並整合至 nginx 或 Apache。此外,本教學包含大多數指南都會忽略的重點:讓用戶端正確「信任」該憑證,而非不斷點擊警告,或在腳本中永久寫死 curl -k。最後,你將學會建立一個僅需五個指令的私有 CA,以應對內部服務數量增加的需求。

首先是決策問題,因為自簽章憑證的使用場景遠比一般認知中少。如果該服務可透過真實 DNS 名稱從公網存取,請停止閱讀,改用 使用 certbot 在 nginx 上取得免費的 Let's Encrypt 憑證Apache 的對應方案。這完全免費、可自動續期,且全球所有瀏覽器都已信任它。在公開網站使用自簽章憑證會養成用戶無視安全警告的惡習,這比使用 plain HTTP 更糟。

在以下情境中,自簽章是正確的工具:無法連接公網時。例如:綁定於 VPS 上 WireGuard tunnel 位址 的管理介面、私有網路中的測試主機、後端之間的服務對服務流量、家用實驗室設備,或是取代 Webmin 在 port 10000 自動產生的預設憑證。Let's Encrypt 無法為 10.8.0.1git.internal.lan 簽發憑證,因為任何公有 CA 都不會在憑證中包含私有 IP 或虛構的 TLD。對於這些名稱,你就是 CA。

以下所有操作皆於全新的 Ubuntu 24.04 環境執行,該系統內建 OpenSSL 3.0.x(請使用 openssl version 確認)。此過程不需要網路連線,完全可以在離線(air-gapped)狀態下完成。

為什麼舊版單行指令產生的憑證會被 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

該指令會進行一系列互動式提問,將您的 hostname 填入 Common Name 欄位,並產生一個不包含 subjectAltName 擴充功能(extension)的憑證。這類憑證無法使用。Chrome 自 2017 年 4 月的 version 58 版本起,便不再讀取 Common Name —— 根據 RFC 2818,CN 匹配在 2000 年就已廢棄 —— 且 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.

任何對 trust-store 的調整都無法修復此錯誤,因為該憑證確實未包含任何名稱資訊。如果您目前正看到 NET::ERR_CERT_COMMON_NAME_INVALID,代表您的憑證缺少 SAN(或 SAN 資訊錯誤),您需要重新產生一個新憑證。幸運的是,修正方法只需一行指令。

製作瀏覽器可接受的憑證:只需一個指令

OpenSSL 在 1.1.1 版本中新增了 -addext 旗標,這代表您不再需要像舊版指南那樣,透過複雜的 config-file 設定來注入 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 直接發行自簽憑證,而非簽署請求 (CSR)。
  • -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: 用於 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.key

nginx 與 Apache 在降低權限前會以 root 身分讀取憑證,因此對它們使用 root:root 模式 600 即可。若金鑰是供以獨立使用者執行並自行載入的服務使用(例如 Node app、Gitea 或 Python daemon),請將 chown 改為該服務的使用者,並維持 600 模式。絕對禁止的操作:使用 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 nginx

nginx -t 必須在 reload 生效前印出 syntax is oktest is successful。若印出 SSL_CTX_use_PrivateKey_file() failed ... key values mismatch,代表憑證與金鑰來自不同的生成週期 — 請參閱 failure-modes 區段。

Wire it into Apache

sudo a2enmod ssl proxy proxy_http

僅靠 ssl 是不夠的:下方的 vhost 使用了 ProxyPass,若缺少 mod_proxymod_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 apache2

configtest 應會回應 Syntax OK。現在從客戶端機器進行測試:

curl -v https://git.internal.lan/

接著你會收到錯誤訊息:

curl: (60) SSL certificate problem: self-signed certificate

這並非錯誤。這是 TLS 的正常機制:curl 不認識你的憑證,因此拒絕與無法驗證身伺服器通訊。下一節將提供正確的修復方法——這與目前網路上常見的一半做法並不相同。

建立客戶端信任 — 以及應拒絕的反模式

首先說明錯誤的修復方式。將 curl -k (或 --insecure) 寫入 script、在 Python requests 中使用 verify=False、或在 Node 中使用 NODE_TLS_REJECT_UNAUTHORIZED=0,這些都無法讓憑證獲得信任。這些做法會關閉憑證驗證,這代表客戶端會接受任何伺服器提供的任何憑證,包含攻擊者植入的憑證。這會讓你白白承受 TLS 的額外開銷,卻失去了原本的核心功能:身份驗證。更糟的是,這些 flag 會擴散:先是貼在一個 cron job,接著是 deploy script,最後進入 production code,直到沒人記得哪些連線原本只是暫時性的。如果一個 verify=False 在除錯階段結束後仍存在,代表設計錯誤。

正確的修復方式是讓每個客戶端 OS 都將此憑證視為受信任的 root。在 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 進行轉換。將自簽章憑證本身加入 root 是可行的,因為自簽章憑證本身即是其自身的 root。

完成後,curlwgetgitapt 以及任何對系統 bundle 使用 OpenSSL 的工具,在不使用任何 flag 的情況下皆會信任該伺服器。少數客戶端擁有各自的信任庫,需要個別處理:

  • Linux 上的 Chrome/Chromium 讀取的是 NSS 資料庫而非系統 store:先執行 sudo apt install libnss3-tools,再針對每個使用者執行 certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt
  • Firefox 有自己的 store:前往 Settings → Privacy & Security → Certificates → Import,或在 about:config 中將 security.enterprise_roots.enabled 改為 true 以讀取系統 store。
  • Python requests 內建了自己的 CA bundle (certifi) 並會忽略系統 store:請傳入 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

逐一安裝憑證會導致擴展性不足:六個服務搭配四台用戶端,就需執行 24 次信任安裝,且每增加一個新服務,工作量就會隨之增加。解決方案是建立私有 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.1

mkcert -install 會建立根憑證並將其註冊至該機器上的所有信任儲存庫;第三條指令會輸出 git.internal.lan+2.pemgit.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,且理想情況下應存放在非簽發對象的伺服器上,因為持有此金鑰的人可以為任何名稱簽發憑證,且用戶端皆會信任。

Expiry and rotation

Public CA 憑證有效期正在縮短 — CA/Browser Forum 已於 2026 年 3 月將新核發的公認憑證上限設為 200 天(原為 398 天),並預計於 2027 年降至 100 天,2029 年 3 月前降至 47 天 — 但這些規則僅約束 公認的 (publicly trusted) CA。您的私有 CA 不受其規範,瀏覽器也不會對手動安裝的根憑證執行這些規則。目前有一個實際適用的限制:Apple 平台會拒絕任何有效期超過 825 天的 TLS 伺服器憑證,無論核發者為何;若需支援 iPhone 或 Mac,請將 leaf 憑證有效期保持在兩年或以下。-days 730 在所有環境中皆符合此標準;使用十年期的 root 搭配兩年期的 leaf 是一種穩定的內部架構。

長效期憑證失效的方式只有一種:在某個無人記得設定的日期,集體且靜默地失效。請檢查您目前的狀況:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate

請將續期作業排入實際行事曆,或設定 cron 在 30 天前提醒您 — 當到期時間進入該秒數範圍內時,openssl x509 -checkend 2592000 -in cert.crt 會以非零值 (non-zero) 退出。若您已在使用 Uptime Kuma 進行狀態監控,其 HTTPS 監控功能可免費標示即將到期的憑證。

使用私有 CA 進行憑證輪替 (Rotation) 非常簡單:重新執行 CSR 與簽署指令、替換檔案並重新載入 web server。由於 root 憑證未變動,因此用戶端不會察覺任何異狀。

Failure modes, with the strings you will see

NET::ERR_CERT_AUTHORITY_INVALID — 這是安裝信任憑證前的「預期」狀態,並非憑證缺陷。若安裝 Root CA 後問題仍持續存在:在 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 比對指紋碼 (fingerprints)。

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 的檔案類型錯誤:在需要憑證的地方提供了 Key 或 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) — 憑證與 Key 不匹配,通常是因為執行了兩次產生指令導致檔案混淆。請透過比對 openssl x509 -in git.internal.crt -noout -pubkey | sha256sumopenssl pkey -in git.internal.key -pubout | sha256sum 來確認;若 Hash 值一致,則為匹配的配對。若兩者不同,請重新同時產生兩者。

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=..." 重新核發。

在不使用 -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 裝置會拒絕任何超過此期限的憑證,無論核發者為何。一個有效期為 10 年的私有根憑證搭配 2 年期(-days 730)的葉片憑證是合理的預設設定;請務必將續期排入行事曆,因為過期的內部憑證會在無人察覺的情況下導致所有服務中斷。

我應該使用自簽章憑證還是 Let's Encrypt?

若服務具有公開 DNS 名稱且可從網際網路存取,請務必使用 Let's Encrypt —— 它免費、自動化,且已被所有用戶端信任。自簽章憑證(或私有 CA)適用於 Let's Encrypt 無法核發的情境:例如私有 IP、僅限內部使用的主機名稱(如 .lan)、實體隔離網路(air-gapped networks),以及刻意隱藏在 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 各自擁有獨立的信任存放區,需要分別新增憑證。

#openssl#tls#self-signed#ubuntu#security