Cách tạo self-signed certificate trên Ubuntu
Hướng dẫn tạo TLS certificate trên Ubuntu 24.04 có đầy đủ SAN. Cách cấu hình nginx/Apache và thêm vào trust store để Chrome không báo lỗi bảo mật.
Những gì bạn đang xây dựng
Một certificate TLS tự ký (self-signed) mà các trình duyệt và client hiện đại thực sự chấp nhận — đúng subjectAltName, quyền hạn key hợp lý, được cấu hình vào nginx hoặc Apache — cộng thêm phần mà hầu hết các hướng dẫn đều bỏ qua: làm cho client của bạn tin tưởng nó một cách chính xác, thay vì cứ phải click bỏ qua cảnh báo và hard-code curl -k vào script mãi mãi. Cuối cùng, bạn sẽ có một private CA chỉ với năm câu lệnh để dùng khi từ một service nội bộ tăng lên thành sáu.
Trước hết là việc đưa ra quyết định, vì certificate tự ký ít khi là công cụ phù hợp hơn so với tần suất người ta sử dụng nó. Nếu service có thể truy cập từ internet công cộng thông qua một DNS name thật, hãy dừng đọc và chuyển sang dùng a free Let's Encrypt certificate with certbot on nginx hoặc the Apache equivalent thay thế. Nó không tốn chi phí, tự động gia hạn, và mọi trình duyệt trên thế giới đều đã tin tưởng nó. Việc dùng certificate tự ký trên một site công khai sẽ tạo thói quen cho người dùng click bỏ qua các cảnh báo bảo mật, điều này còn tệ hơn cả việc dùng HTTP thuần túy.
Tự ký là công cụ phù hợp khi không có internet công cộng trong kịch bản: một admin panel gắn với a WireGuard tunnel address on your VPS, một staging box trong mạng nội bộ, traffic service-to-service giữa các backend, một thiết bị home-lab, hoặc thay thế certificate tạm thời mà Webmin generates for itself on port 10000. Let's Encrypt dù sao cũng không thể cấp cho 10.8.0.1 hay git.internal.lan — không có CA công khai nào đưa một IP riêng tư hoặc một TLD tự chế vào certificate cả. Với những tên miền đó, bạn chính là CA.
Mọi thứ dưới đây chạy trên một máy Ubuntu 24.04 mới, máy này đi kèm OpenSSL 3.0.x (kiểm tra bằng openssl version). Không có gì ở đây cần kết nối internet; tất cả đều hoạt động được trong môi trường air-gapped.
Tại sao câu lệnh một dòng cũ tạo ra cert mà Chrome từ chối
Câu lệnh mà mọi hướng dẫn trước năm 2017 cung cấp cho bạn trông như thế này:
# 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.crtNó sẽ hỏi một loạt câu hỏi tương tác, đưa hostname của bạn vào trường Common Name, và tạo ra một certificate không có phần mở rộng subjectAltName. Certificate đó coi như vô dụng ngay khi vừa tạo xong. Chrome đã ngừng đọc Common Name từ phiên bản 58, vào tháng 4 năm 2017 — RFC 2818 đã loại bỏ việc khớp CN từ năm 2000 — và Firefox, Safari, curl, và Python cũng hoạt động tương tự. Một certificate xác định server của nó thông qua phần mở rộng SAN hoặc không gì cả, và trình duyệt sẽ báo cho bạn chính xác bằng những dòng này:
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.Không có việc can thiệp vào trust-store nào có thể sửa được lỗi đó, vì certificate thực sự không định danh cho bất cứ thứ gì. Nếu bạn đang thấy NET::ERR_CERT_COMMON_NAME_INVALID ngay lúc này, certificate của bạn không có SAN (hoặc sai SAN) và bạn cần tạo một cái mới. May mắn thay, cách sửa chỉ mất một câu lệnh.
Tạo một certificate mà trình duyệt chấp nhận: một câu lệnh
OpenSSL đã bổ sung flag -addext trong bản 1.1.1, nghĩa là bạn không còn cần các kỹ thuật dùng file config phức tạp như các hướng dẫn cũ để chèn SAN. Trên 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"Ý nghĩa của từng flag:
-x509xuất trực tiếp một certificate tự ký thay vì một yêu cầu ký (signing request).-newkey rsa:4096tạo một key mới ngay trong bước đó. RSA 4096 không làm khó các client cũ; nếu mọi thứ kết nối đều là hiện đại,-newkey ec -pkeyopt ec_paramgen_curve:P-256sẽ nhỏ hơn và nhanh hơn.-noenclà cách viết của OpenSSL 3.x cho-nodescũ: không có passphrase trên key. Cả hai cách viết đều hoạt động. Một key có passphrase nghĩa là nginx sẽ treo để chờ nhập input mỗi khi khởi động, nên với server key, bạn nên dùng cách này.-days 730— hai năm; chi tiết hơn về con số này ở phần hết hạn.-subjtrả lời các câu hỏi tương tác ngay trong dòng lệnh. CN hiện tại chỉ mang tính hình thức, nhưng cứ set nó là tên chính; một số công cụ vẫn hiển thị nó.-addext "subjectAltName=..."là flag quan trọng nhất. Hãy liệt kê mọi tên và mọi IP mà client sẽ nhập:DNS:entries cho hostnames (dùng wildcard nhưDNS:*.internal.lancũng được),IP:entries cho các địa chỉ. Nếu có ai truy cập vàohttps://10.8.0.1, thì entryIP:10.8.0.1phải có ở đó — một SAN chỉ có DNS sẽ khiến họ gặp lại lỗiNET::ERR_CERT_COMMON_NAME_INVALID.
Xác nhận SAN đã được chèn vào trước khi cấu hình bất cứ thứ gì:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltNameOutput đúng:
X509v3 Subject Alternative Name:
DNS:git.internal.lan, IP Address:10.8.0.1Nếu nó in ra No extensions in certificate, certificate không có SAN và trình duyệt sẽ từ chối nó — hãy tạo lại thay vì tiếp tục cố gắng.
Khóa key
Một private key mà mọi user trên máy đều đọc được thì không còn là private key nữa. Trên Ubuntu, /etc/ssl/private đã là 710 root:ssl-cert, giúp ngăn chặn các ánh nhìn tò mò, nhưng hãy set quyền cho chính file đó một cách rõ ràng:
sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.keyCả nginx và Apache đều đọc certificate với quyền root trước khi hạ đặc quyền, nên mode root:root 600 hoạt động tốt cho chúng. Nếu key dành cho một service chạy dưới user riêng và tự load key — như một app Node, Gitea, hay một daemon Python — hãy set chown cho user của service đó, vẫn giữ mode 600. Những gì bạn tuyệt đối không làm: mode 644, copy vào git repository, hoặc copy vào /tmp.
Cấu hình vào 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 phải in ra syntax is ok và test is successful trước khi lệnh reload có tác dụng. Nếu nó in ra SSL_CTX_use_PrivateKey_file() failed ... key values mismatch, certificate và key thuộc về hai lần tạo khác nhau — xem phần các lỗi thường gặp.
Cấu hình vào Apache
sudo a2enmod ssl proxy proxy_httpssl thôi là chưa đủ ở đây: vhost dưới đây sử dụng ProxyPass, và nếu không có mod_proxy và mod_proxy_http, việc test config sẽ chết với lỗi Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration. Lưu vhost dưới dạng /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 sẽ trả lời Syntax OK. Bây giờ hãy test từ một máy client:
curl -v https://git.internal.lan/và bạn sẽ nhận được lỗi:
curl: (60) SSL certificate problem: self-signed certificateĐó không phải là lỗi. Đó là TLS đang hoạt động: curl chưa từng nghe nói về certificate của bạn và nó từ chối nói chuyện với một server mà nó không thể xác thực. Phần tiếp theo mới là cách sửa thực sự — và nó không giống như cách mà một nửa internet đang làm lúc này.
Làm cho client tin tưởng nó — và các anti-pattern cần tránh
Trước hết là các cách sửa sai, được gọi đúng tên của chúng. curl -k (hoặc --insecure) nhúng vào script, verify=False trong Python requests, NODE_TLS_REJECT_UNAUTHORIZED=0 trong Node — không có cách nào trong số này làm cho certificate của bạn được tin tưởng. Chúng tắt tính năng xác thực certificate (off), nghĩa là client sẽ vui vẻ nói chuyện với bất kỳ server nào trình ra bất kỳ certificate nào, kể cả certificate của kẻ tấn công nằm trên đường truyền. Bạn vẫn phải chịu overhead của TLS nhưng lại mất đi tính năng xác thực vốn là mục đích của nó. Tệ hơn, các flag này sẽ lây lan: được dán vào một cron job, rồi đến một script deploy, rồi đến code production, cho đến khi không ai nhớ nổi kết nối nào là tạm thời. Nếu một verify=False còn tồn tại sau phiên debug tạo ra nó, thì thiết kế đó đã sai.
Cách sửa đúng là dạy cho mỗi OS của client rằng certificate này là một root đáng tin cậy. Trên các client Ubuntu và Debian:
sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificatesDòng quan trọng trong output (một block Running hooks in /etc/ca-certificates/update.d... sẽ theo sau nó):
Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.Có hai lỗi cần lưu ý ở các dòng này. File phải kết thúc bằng .crt — phần mở rộng .pem sẽ bị bỏ qua một cách âm thầm và bạn sẽ nhận được 0 added mà không có thông báo lỗi. Và nội dung phải là PEM — file phải bắt đầu bằng -----BEGIN CERTIFICATE-----; hãy convert file binary DER trước bằng openssl x509 -inform der -in file.der -out file.crt. Việc thêm chính certificate tự ký đó như một root sẽ hoạt động vì một certificate tự ký chính là root của chính nó.
Sau đó, curl, wget, git, apt, và bất cứ thứ gì khác sử dụng OpenSSL với system bundle sẽ tin tưởng server mà không cần bất kỳ flag nào. Một số client có trust store riêng và cần xử lý riêng biệt:
- Chrome/Chromium trên Linux đọc một database NSS, không phải system store:
sudo apt install libnss3-tools, sau đó làcertutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crtcho mỗi user. - Firefox có store riêng: Settings → Privacy & Security → Certificates → Import, hoặc chuyển
security.enterprise_roots.enabledthànhtruetrongabout:configđể nó đọc system store. - Python requests đi kèm CA bundle riêng (certifi) và bỏ qua system store: truyền
verify="/usr/local/share/ca-certificates/git.internal.crt"hoặc exportREQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt. - Node.js: export
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt.
Trên các client Windows, hãy double-click vào .crt và cài đặt nó vào Trusted Root Certification Authorities; trên macOS, hãy thêm nó vào System keychain trong Keychain Access và đánh dấu là Always Trust.
Một root cho nhiều services: một private CA nhỏ
Tin tưởng theo từng certificate sẽ không thể mở rộng được ngay lập tức: sáu services nhân với bốn máy client là hai mươi tư lần cài đặt trust, và mỗi service mới lại thêm nhiều hơn nữa. Cách sửa là dùng một private CA — client tin tưởng một root, và bạn ký certificate của mỗi service bằng root đó.
Lựa chọn thân thiện là mkcert, có sẵn trong repo của Ubuntu 24.04 và xử lý được các NSS stores (Chrome, Firefox) mà update-ca-certificates bỏ lỡ:
sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1mkcert -install tạo một root và đăng ký nó vào mọi trust store trên máy đó; lệnh thứ ba xuất ra git.internal.lan+2.pem và git.internal.lan+2-key.pem, sẵn sàng để đưa vào các đoạn code nginx hoặc Apache ở trên. Giả định thiết kế của nó là một máy phát triển — root key nằm trên bất kỳ máy nào đã chạy -install — vì vậy nó hoàn hảo cho một laptop dev nhưng không phù hợp cho một dàn server.
Đối với server, OpenSSL thuần túy có thể thực hiện toàn bộ CA chỉ với năm câu lệnh:
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.crtCạm bẫy nằm ở câu lệnh cuối cùng: openssl x509 -req mặc định loại bỏ tất cả các extension từ CSR, bao gồm cả SAN mà bạn đã cẩn thận thêm vào. -copy_extensions copy (một tùy chọn của OpenSSL 3.x, nên nó hoạt động trên 24.04) sẽ giữ chúng lại; nếu bỏ qua nó, certificate đã ký sẽ không có SAN, và Chrome sẽ chào bạn bằng lỗi NET::ERR_CERT_COMMON_NAME_INVALID một lần nữa. Hãy kiểm tra lại bằng lệnh openssl x509 -noout -ext subjectAltName tương tự như trước.
Phân phối lab-ca.crt cho các client thông qua các bước cài đặt trust-store ở trên — chỉ cần làm một lần duy nhất cho mỗi máy. Hãy bảo vệ lab-ca.key như báu vật: mode 600, lý tưởng nhất là giữ nó trên một máy không phải là một trong các server mà nó ký, vì bất kỳ ai giữ nó đều có thể tạo ra một certificate cho bất kỳ tên miền nào mà client của bạn sẽ tin tưởng.
Hết hạn và xoay vòng (rotation)
Tuổi thọ certificate của CA công khai đang ngắn lại — CA/Browser Forum đã giới hạn các certificate được tin tưởng công khai mới phát hành ở mức 200 ngày vào tháng 3 năm 2026 (giảm từ 398), xuống còn 100 ngày vào năm 2027 và 47 ngày vào tháng 3 năm 2029 — nhưng những quy tắc đó chỉ ràng buộc các CA được tin tưởng công khai. Private CA của bạn không bị chi phối bởi chúng, và trình duyệt không bắt buộc chúng đối với các root được cài đặt thủ công. Có một quy định thực tế cần lưu ý: các nền tảng của Apple từ chối bất kỳ server certificate TLS nào có thời hạn dài hơn 825 ngày bất kể ai cấp, vì vậy nếu iPhone hoặc Mac sẽ kết nối, hãy giữ các leaf certificate trong vòng hai năm hoặc ít hơn. -days 730 vượt qua ngưỡng đó ở mọi nơi; một root mười năm với các leaf hai năm là một cấu hình nội bộ hợp lý.
Các certificate có thời hạn dài thường thất bại theo một cách duy nhất: âm thầm, cùng một lúc, vào một ngày mà không ai nhớ đã chọn. Hãy kiểm tra những gì bạn đang có:
openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddateHãy đưa việc gia hạn vào một lịch thực tế, hoặc để cron nhắc nhở bạn trước 30 ngày — openssl x509 -checkend 2592000 -in cert.crt sẽ thoát với mã lỗi khác không khi thời gian hết hạn nằm trong khoảng đó. Nếu bạn đã chạy Uptime Kuma for status monitoring, các trình giám sát HTTPS của nó sẽ cảnh báo khi certificate sắp hết hạn miễn phí.
Xoay vòng với một private CA rất đơn giản: chạy lại các lệnh tạo CSR và ký, thay thế các file, reload web server. Root không thay đổi, nên không client nào nhận ra sự khác biệt.
Các lỗi thường gặp, kèm theo các thông báo bạn sẽ thấy
NET::ERR_CERT_AUTHORITY_INVALID — trạng thái dự kiến trước khi bạn cài đặt trust, không phải là lỗi của certificate. Nếu nó vẫn tồn tại sau khi bạn đã cài đặt root: trên Linux, Chrome đọc NSS thay vì system store (xem bước certutil); hoặc file đã copy không kết thúc bằng .crt và update-ca-certificates nói 0 added; hoặc server đang trình bày một certificate khác với certificate bạn đã tin tưởng — hãy so sánh fingerprint bằng openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256.
NET::ERR_CERT_COMMON_NAME_INVALID — certificate không có SAN, hoặc SAN không bao gồm tên trong thanh địa chỉ. Trường hợp điển hình: SAN liệt kê DNS:git.internal.lan nhưng người dùng lại truy cập vào https://10.8.0.1. Thay đổi trust-store không thể sửa được lỗi này; hãy cấp lại với entry còn thiếu.
curl: (60) SSL certificate problem: self-signed certificate — curl không tin tưởng certificate. Biến thể self-signed certificate in certificate chain có nghĩa tương tự cho một cert được ký bởi private CA của bạn. Cách sửa nhanh: curl --cacert lab-ca.crt https://...; cách sửa lâu dài: dùng trust store. Không phải -k.
unable to load certificate ... Expecting: TRUSTED CERTIFICATE (hoặc Expecting: CERTIFICATE REQUEST, hoặc no start line) — nhầm lẫn PEM. Bạn đã đưa cho OpenSSL sai loại file: một key hoặc CSR ở nơi nó mong đợi một certificate, hoặc một file binary DER ở nơi nó mong đợi PEM. head -1 filename cho bạn biết bạn thực sự có gì — một certificate bắt đầu bằng -----BEGIN CERTIFICATE-----. Đối với DER, hãy convert bằng openssl x509 -inform der -in file.der -out file.crt.
nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch) — certificate và key không khớp nhau, thường là do lệnh tạo được chạy hai lần và các file bị lẫn lộn. Xác nhận bằng cách so sánh openssl x509 -in git.internal.crt -noout -pubkey | sha256sum với openssl pkey -in git.internal.key -pubout | sha256sum; hash khớp nhau nghĩa là một cặp khớp nhau. Nếu chúng khác nhau, hãy tạo lại cả hai cùng lúc.
FAQ
Tại sao Chrome vẫn báo "Not secure" sau khi tôi đã tạo certificate tự ký?
Nếu lỗi là NET::ERR_CERT_AUTHORITY_INVALID, certificate đó ổn — Chrome đơn giản là chưa có lý do để tin tưởng nó. Hãy cài đặt nó (hoặc private CA root của bạn) vào trust store của client, và hãy nhớ rằng trên Linux, Chrome sử dụng database NSS thông qua certutil, chứ không phải system store. Nếu lỗi là NET::ERR_CERT_COMMON_NAME_INVALID, certificate thiếu Subject Alternative Name khớp với URL và phải được cấp lại với -addext "subjectAltName=...".
Làm thế nào để curl tin tưởng một certificate tự ký mà không dùng -k?
Hãy copy certificate (định dạng PEM, phần mở rộng .crt) vào /usr/local/share/ca-certificates/ và chạy sudo update-ca-certificates — output phải báo 1 added. Từ đó, curl sẽ xác thực nó như bất kỳ certificate công khai nào. Để thực hiện một request duy nhất mà không chạm vào hệ thống, curl --cacert /path/to/cert.crt sẽ xác thực chỉ với file đó; -k sẽ vô hiệu hóa hoàn toàn việc xác thực và không nên dùng trong bất kỳ script nào.
Một certificate tự ký có thể có thời hạn bao lâu?
Về mặt kỹ thuật là bao lâu tùy bạn — các giới hạn của CA/Browser Forum (hiện là 200 ngày, đến năm 2029 là 47 ngày) chỉ ràng buộc các CA được tin tưởng công khai, không phải trust riêng tư. Trong thực tế, hãy giới hạn certificate server ở mức 825 ngày, vì các thiết bị Apple sẽ từ chối bất kỳ thứ gì dài hơn bất kể ai cấp. Một root riêng tư mười năm với các leaf certificate hai năm (-days 730) là một mặc định hợp lý; chỉ cần lên lịch gia hạn, vì một certificate nội bộ hết hạn sẽ làm sập mọi thứ một cách âm thầm vào một ngày mà không ai nhớ.
Tôi nên dùng certificate tự ký hay Let's Encrypt?
Nếu service có DNS name công khai và có thể truy cập từ internet, hãy luôn dùng Let's Encrypt — miễn phí, tự động, và đã được mọi client tin tưởng. Tự ký (hoặc private CA) dành cho những thứ mà Let's Encrypt không thể cấp: IP riêng, hostname nội bộ như .lan, mạng air-gapped, và các service cố tình ẩn sau VPN. Quyết định nằm ở khả năng truy cập và cách đặt tên, không phải ở độ mạnh mã hóa — thuật toán mã hóa là như nhau.
Tại sao certificate của tôi vẫn bị từ chối ngay cả sau khi đã thêm vào /usr/local/share/ca-certificates?
Hãy kiểm tra ba thứ. File phải kết thúc bằng .crt — phần mở rộng .pem sẽ bị bỏ qua âm thầm và update-ca-certificates sẽ báo 0 added. Nội dung phải là văn bản PEM bắt đầu bằng -----BEGIN CERTIFICATE-----, không phải binary DER. Và ứng dụng phải thực sự sử dụng system store — Chrome trên Linux, Firefox, Python requests, Node.js, và Java đều giữ một trust store riêng và cần được thêm certificate riêng biệt.