- Tổng quan
- Kiến trúc
- Vì sao trình duyệt báo lỗi chứng chỉ – và cách tránh
- Các file chứng chỉ và vai trò
- Cài đặt OpenSSL 3.5 LTS trên Windows
- Tạo chứng chỉ bằng Script
- Tạo chứng chỉ thủ công (không dùng Script)
- Kiểm tra tính chính xác của các file chứng chỉ
- Dựng web Node.js chạy song song HTTP và HTTPS
- Vị trí lưu trữ: code, server, client
- Cài Root CA lên máy Client
- Trình duyệt kiểm tra chứng chỉ như thế nào
- Kiểm thử và nghiệm thu
- Rủi ro và lưu ý
- Gia hạn và thay chứng chỉ
- Phụ lục A: HTTP và HTTPS dùng chung một cổng
- Phụ lục B: Giới hạn phạm vi Root CA bằng nameConstraints
- Tham khảo
Tổng quan
Trong bài Cấu hình SSL HTTPS cho Localhost trong Node.js mình dùng gói mkcert cho nhanh. Bài này làm lại từ đầu chỉ với OpenSSL, để hiểu rõ từng file, từng extension và kiểm soát được thời hạn, phạm vi của chứng chỉ.
Mục tiêu: một web Node.js tối giản, chạy song song HTTP và HTTPS trên server Windows có IP 192.168.1.2. Các máy trong mạng LAN mở https://192.168.1.2:3443 thấy ổ khóa, không gặp NET::ERR_CERT_COMMON_NAME_INVALID hay NET::ERR_CERT_AUTHORITY_INVALID.
| Hạng mục | Giá trị |
|---|---|
| Công cụ | OpenSSL 3.5 LTS (phát hành 08/04/2025, hỗ trợ tới 08/04/2030) |
| Môi trường | Windows 10/11, Windows Server 2019+ |
| Root CA | Tự tạo, RSA 4096, SHA-256, 10 năm (3650 ngày) |
| Cert server | RSA 2048, SHA-256, 10 năm (Windows/Android) hoặc 825 ngày (có macOS/iOS) |
| subjectAltName | IP:192.168.1.2, IP:127.0.0.1, IP:::1, DNS:localhost |
| Web | Node.js LTS + Express, HTTP :3000, HTTPS :3443 |
| Phụ lục | Gộp HTTP + HTTPS vào một cổng :3000 |
Vì sao không dùng Let’s Encrypt? Let’s Encrypt chỉ cấp cho tên miền hoặc IP công khai và cần xác thực qua Internet. IP nội bộ
192.168.x.xthì chỉ có cách tự làm CA nội bộ.
Kiến trúc
flowchart LR
subgraph CA["① Máy tạo chứng chỉ"]
O["OpenSSL 3.5 LTS<br/>gen-certs.ps1 / .bat"] --> K["rootCA.key 🔐<br/>rootCA.crt"]
K --> S["server.key<br/>server.crt"]
end
subgraph SV["② Server Windows 192.168.1.2"]
FW["Windows Firewall<br/>TCP 3000, 3443"] --> APP["Node.js + Express"]
APP --> H1["HTTP :3000"]
APP --> H2["HTTPS :3443"]
end
subgraph CL["③ Client"]
B["Trình duyệt<br/>đã cài rootCA.crt"]
end
S -- "copy server.key + server.crt" --> APP
K -. "chỉ rootCA.crt" .-> B
B -- "HTTPS 192.168.1.2:3443" --> H2
B -. "HTTP 192.168.1.2:3000" .-> H1
Ý tưởng cốt lõi: trình duyệt không tin server, trình duyệt tin Root CA. Ta tự làm một Root CA, cài nó vào kho tin cậy của client, rồi dùng nó ký chứng chỉ cho server. Muốn thêm server khác chỉ việc ký thêm, client không phải cài lại gì.
Vì sao trình duyệt báo lỗi chứng chỉ – và cách tránh
| Lỗi trên Chrome/Edge | Nguyên nhân hay gặp | Cách tránh |
|---|---|---|
NET::ERR_CERT_AUTHORITY_INVALID |
Cert tự ký (self-signed) đơn lẻ, hoặc chưa cài rootCA.crt vào Trusted Root Certification Authorities, hoặc cài nhầm vào kho Personal / Intermediate |
Dùng Root CA riêng ký cert server, cài rootCA.crt đúng kho Root, khởi động lại trình duyệt |
NET::ERR_CERT_COMMON_NAME_INVALID |
Cert không có subjectAltName (từ Chrome 58, CN bị bỏ qua) hoặc ghi IP thành DNS.1 = 192.168.1.2 |
Khai báo IP.1 = 192.168.1.2 trong [alt_names], thêm DNS.1 = localhost nếu cần |
NET::ERR_CERT_DATE_INVALID |
Cert hết hạn, hoặc đồng hồ máy client/server sai | Theo dõi ngày hết hạn, đồng bộ NTP |
NET::ERR_CERT_VALIDITY_TOO_LONG |
Cert server > 825 ngày khi mở trên macOS/iOS | Cấp cert server ≤ 825 ngày nếu có thiết bị Apple |
NET::ERR_CERT_INVALID |
Thiếu extendedKeyUsage = serverAuth, cert server lại có CA:TRUE, file hỏng |
Dùng đúng file server.ext trong bài |
ERR_SSL_PROTOCOL_ERROR |
Gõ https:// vào cổng HTTP 3000 |
HTTPS dùng cổng 3443 (hoặc xem Phụ lục A) |
ERR_SSL_VERSION_OR_CIPHER_MISMATCH |
Client quá cũ, server ép TLS cao | Giữ minVersion: 'TLSv1.2' |
Hai điểm hay bị hiểu nhầm nhất:
- CN không còn giá trị. Ghi
CN = 192.168.1.2mà không có SAN thì Chrome vẫn báoCOMMON_NAME_INVALID. - IP phải nằm trong
IP.x, không phảiDNS.x.DNS:192.168.1.2là một tên miền tên “192.168.1.2”, không khớp với địa chỉ IP.
Các file chứng chỉ và vai trò
| File | Loại | Vai trò | Bí mật? | Ở đâu |
|---|---|---|---|---|
rootCA.key |
Khóa riêng RSA 4096 | Ký mọi cert server. Ai có file này có thể giả mạo mọi website trên máy đã tin CA | 🔴 Rất bí mật | Kho CA, offline |
rootCA.crt |
Chứng chỉ X.509 tự ký | “Mỏ neo tin cậy” cài vào client | 🟢 Công khai | Kho CA + mọi client |
rootCA.cnf |
Cấu hình OpenSSL | Subject, CA:TRUE, keyCertSign |
Không | Kho CA |
rootCA.srl |
Text | Số serial kế tiếp, tránh trùng serial | Không | Kho CA |
server.key |
Khóa riêng RSA 2048 | Server dùng để bắt tay TLS | 🔵 Bí mật | Server |
server.csr |
Yêu cầu ký (CSR) | Chứa public key + subject gửi cho CA. Ký xong có thể xóa | Không | Tạm thời |
server.cnf |
Cấu hình OpenSSL | Subject của CSR | Không | Repo / máy CA |
server.ext |
Cấu hình extension | SAN, serverAuth, CA:FALSE |
Không | Repo / máy CA |
server.crt |
Chứng chỉ X.509 | Server gửi cho trình duyệt khi bắt tay TLS | 🟢 Công khai | Server |
Tất cả ở định dạng PEM (text Base64, bắt đầu bằng -----BEGIN ...-----). Windows mở được .crt PEM trực tiếp, không cần đổi sang .cer/.der.
flowchart LR
RC["rootCA.cnf"] --> RK
RK["rootCA.key 🔐"] -- "req -x509 (tự ký)" --> RT["rootCA.crt 🌐"]
SK["server.key 🔑"] -- "req -new" --> CSR["server.csr (tạm)"]
SE["server.ext<br/>SAN + serverAuth"] --> SC
CSR -- "x509 -req" --> SC["server.crt 🌐"]
RK -- "ký" --> SC
SC -. "Issuer = Subject" .-> RT
SK <-. "cùng cặp khóa" .-> SC
Cài đặt OpenSSL 3.5 LTS trên Windows
OpenSSL không ship sẵn bản Windows, ta dùng bản build của bên thứ ba. Chọn một trong các cách sau:
| Cách | Lệnh / thao tác | Ghi chú |
|---|---|---|
| winget | winget search openssl rồi winget install --id ShiningLight.OpenSSL.Light -e --version 3.5.x |
Thay 3.5.x bằng bản 3.5 mới nhất; xem danh sách bằng winget show ShiningLight.OpenSSL.Light --versions |
| Installer | Tải Win64 OpenSSL v3.5.x Light tại slproweb.com | Chọn copy DLL vào thư mục bin của OpenSSL |
| Zip (không cài) | Tải OpenSSL 3.5 LTS tại FireDaemon rồi giải nén | Hợp với server không muốn cài thêm phần mềm |
| Chocolatey | choco install openssl --version=3.5.x |
Nếu đã dùng Chocolatey |
Thêm vào PATH (PowerShell Run as Administrator), nếu installer chưa làm:
$bin = 'C:\Program Files\OpenSSL-Win64\bin'
$old = [Environment]::GetEnvironmentVariable('Path', 'Machine')
if ($old -notlike "*$bin*") { [Environment]::SetEnvironmentVariable('Path', "$old;$bin", 'Machine') }
Mở cửa sổ mới rồi kiểm tra:
where openssl
openssl version -a
Kết quả đúng (số bản vá có thể khác):
C:\Program Files\OpenSSL-Win64\bin\openssl.exe
OpenSSL 3.5.x ... (Library: OpenSSL 3.5.x ...)
⚠️ Git for Windows cũng có
openssl.exeriêng (C:\Program Files\Git\usr\bin\openssl.exe). Nếuwhere openssltrả về nhiều dòng, dòng đầu tiên là bản sẽ chạy. Hãy chắc chắn đó là bản 3.5.
Tạo chứng chỉ bằng Script
Cả hai script làm cùng một việc:
- Tạo Root CA trong
C:\PKI\DevRootCA\(nếu đã có thì dùng lại, không tạo đè). - Tạo
server.key+server.crttrongcerts\của dự án, SAN gồm IP truyền vào +127.0.0.1,::1,localhost. - Copy
rootCA.crt(bản public) vàocerts\để phát cho client. - Tự kiểm tra: chain, IP, khớp khóa.
- (Tùy chọn) Cài Root CA vào kho Trusted Root của máy đang chạy.
PowerShell: gen-certs.ps1
Lưu tại scripts\gen-certs.ps1:
<#
.SYNOPSIS
Tao Root CA noi bo + chung chi server (SAN: IP + localhost) bang OpenSSL 3.5 LTS.
.EXAMPLE
.\gen-certs.ps1 # IP 192.168.1.2, server 10 nam
.\gen-certs.ps1 -ServerDays 825 # tuong thich macOS/iOS
.\gen-certs.ps1 -IP 192.168.1.2 -InstallRoot # chay PowerShell "Run as Administrator"
#>
[CmdletBinding()]
param(
[string] $IP = '192.168.1.2',
[string[]] $DnsNames = @('localhost'),
[int] $CaDays = 3650, # Root CA: 10 nam
[int] $ServerDays = 3650, # Server: 10 nam (dung 825 cho Apple)
[string] $CaDir = 'C:\PKI\DevRootCA', # NGOAI repo
[string] $OutDir = (Join-Path $PSScriptRoot '..\certs'),
[string] $Org = 'FSTV Dev', # chi dung ky tu ASCII
[switch] $InstallRoot, # cai rootCA.crt vao LocalMachine\Root
[switch] $Force # tao lai cert server du da ton tai
)
$ErrorActionPreference = 'Stop'
function Invoke-OpenSSL {
& openssl @args
if ($LASTEXITCODE -ne 0) { throw "openssl $($args -join ' ') -> exit $LASTEXITCODE" }
}
# 0) Kiem tra OpenSSL
if (-not (Get-Command openssl -ErrorAction SilentlyContinue)) { throw 'Khong tim thay openssl trong PATH.' }
$ver = (& openssl version)
Write-Host "[i] $ver ($((Get-Command openssl).Source))"
if ($ver -notmatch '^OpenSSL 3\.') { throw 'Can OpenSSL 3.x (khuyen nghi 3.5 LTS).' }
if ($ServerDays -gt 825) { Write-Warning "ServerDays=$ServerDays > 825: Safari/Chrome tren macOS, iOS se bao NET::ERR_CERT_VALIDITY_TOO_LONG." }
New-Item -ItemType Directory -Force -Path $CaDir, $OutDir | Out-Null
$OutDir = (Resolve-Path $OutDir).Path
$caKey = Join-Path $CaDir 'rootCA.key'; $caCrt = Join-Path $CaDir 'rootCA.crt'
$caCnf = Join-Path $CaDir 'rootCA.cnf'; $caSrl = Join-Path $CaDir 'rootCA.srl'
$srvKey = Join-Path $OutDir 'server.key'; $srvCsr = Join-Path $OutDir 'server.csr'
$srvCrt = Join-Path $OutDir 'server.crt'; $srvCnf = Join-Path $OutDir 'server.cnf'
$srvExt = Join-Path $OutDir 'server.ext'
# 1) Root CA (tao 1 lan, dung lai cho cac lan sau)
if (Test-Path $caKey) {
Write-Host "[i] Dung lai Root CA co san: $caCrt"
} else {
@"
[ req ]
distinguished_name = dn
prompt = no
x509_extensions = v3_ca
[ dn ]
C = VN
O = $Org
CN = $Org Root CA
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
"@ | Set-Content -Path $caCnf -Encoding ascii
Invoke-OpenSSL genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out $caKey
Invoke-OpenSSL req -x509 -new -key $caKey -sha256 -days $CaDays -config $caCnf -out $caCrt
if ($env:OS -eq 'Windows_NT') { # chi tai khoan hien tai doc duoc rootCA.key
icacls $caKey /inheritance:r /grant:r "$($env:USERNAME):(F)" | Out-Null
}
Write-Host "[+] Da tao Root CA ($CaDays ngay): $caCrt"
}
# 2) Chung chi server
if ((Test-Path $srvCrt) -and -not $Force) { throw "Da co $srvCrt. Them -Force de tao lai." }
$alt = @(); $i = 1
foreach ($d in $DnsNames) { $alt += "DNS.$i = $d"; $i++ }
$i = 1
foreach ($addr in @($IP, '127.0.0.1', '::1') | Select-Object -Unique) { $alt += "IP.$i = $addr"; $i++ }
@"
[ req ]
distinguished_name = dn
prompt = no
[ dn ]
C = VN
O = $Org
CN = $IP
"@ | Set-Content -Path $srvCnf -Encoding ascii
@"
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
subjectAltName = @alt_names
[ alt_names ]
$($alt -join "`n")
"@ | Set-Content -Path $srvExt -Encoding ascii
Invoke-OpenSSL genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out $srvKey
Invoke-OpenSSL req -new -key $srvKey -config $srvCnf -out $srvCsr
Invoke-OpenSSL x509 -req -in $srvCsr -CA $caCrt -CAkey $caKey -CAserial $caSrl -CAcreateserial `
-days $ServerDays -sha256 -extfile $srvExt -out $srvCrt
Remove-Item $srvCsr
Copy-Item $caCrt (Join-Path $OutDir 'rootCA.crt') -Force # ban public de phat cho client
# 3) Tu kiem tra
Write-Host "`n[?] Kiem tra:"
Invoke-OpenSSL verify -CAfile $caCrt -verify_ip $IP $srvCrt
Invoke-OpenSSL x509 -in $srvCrt -noout -subject -enddate -ext subjectAltName
$h1 = (& openssl x509 -noout -pubkey -in $srvCrt | & openssl sha256)
$h2 = (& openssl pkey -in $srvKey -pubout | & openssl sha256)
if ($h1 -ne $h2) { throw 'server.key KHONG khop server.crt' } else { Write-Host '[+] server.key khop server.crt' }
# 4) (Tuy chon) cai Root CA vao Windows
if ($InstallRoot) {
certutil -addstore -f Root $caCrt
if ($LASTEXITCODE -ne 0) { throw 'certutil that bai - hay chay PowerShell voi quyen Administrator.' }
}
Write-Host "`n[OK] Server: $srvKey, $srvCrt"
Write-Host " Client: cai $caCrt vao Trusted Root. KHONG chia se $caKey."
Chạy:
cd C:\Projects\web-https-demo
# Có máy Apple trong mạng -> 825 ngày
powershell -ExecutionPolicy Bypass -File .\scripts\gen-certs.ps1 -IP 192.168.1.2 -ServerDays 825
# Chỉ Windows/Android -> 10 năm (mặc định), đồng thời cài Root CA vào máy này (cần Admin)
powershell -ExecutionPolicy Bypass -File .\scripts\gen-certs.ps1 -IP 192.168.1.2 -InstallRoot
# Cấp lại cert server (giữ nguyên Root CA)
powershell -ExecutionPolicy Bypass -File .\scripts\gen-certs.ps1 -Force
Kết quả mong đợi:
[i] OpenSSL 3.5.x ... (C:\Program Files\OpenSSL-Win64\bin\openssl.exe)
[+] Da tao Root CA (3650 ngay): C:\PKI\DevRootCA\rootCA.crt
Certificate request self-signature ok
subject=C = VN, O = FSTV Dev, CN = 192.168.1.2
[?] Kiem tra:
C:\Projects\web-https-demo\certs\server.crt: OK
subject=C = VN, O = FSTV Dev, CN = 192.168.1.2
notAfter=Feb 11 05:18:28 2029 GMT
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:192.168.1.2, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1
[+] server.key khop server.crt
💡 PowerShell không phân biệt hoa thường tên biến:
$ipvà$IPlà một. Vì vậy vòng lặp trong script dùng$addr– một lỗi rất dễ dính khi tự viết script tương tự.
Batch: gen-certs.bat
Dành cho máy bị chặn PowerShell script. Lưu tại scripts\gen-certs.bat (thông báo không dấu để tránh lỗi code page):
@echo off
rem ==========================================================================
rem gen-certs.bat [IP] [SERVER_DAYS] [install]
rem Vi du: gen-certs.bat 192.168.1.2 825 install (cmd "Run as administrator")
rem ==========================================================================
setlocal EnableExtensions
set "IP=%~1"
if "%IP%"=="" set "IP=192.168.1.2"
set "SERVER_DAYS=%~2"
if "%SERVER_DAYS%"=="" set "SERVER_DAYS=3650"
set "CA_DAYS=3650"
set "ORG=FSTV Dev"
set "CA_DIR=C:\PKI\DevRootCA"
set "OUT_DIR=%~dp0..\certs"
where openssl >nul 2>&1 || (echo [LOI] Khong tim thay openssl trong PATH & exit /b 1)
openssl version
if %SERVER_DAYS% GTR 825 echo [CANH BAO] Server ^> 825 ngay: macOS/iOS se bao ERR_CERT_VALIDITY_TOO_LONG
if not exist "%CA_DIR%" mkdir "%CA_DIR%"
if not exist "%OUT_DIR%" mkdir "%OUT_DIR%"
rem ---- 1) Root CA: tao 1 lan -------------------------------------------------
if exist "%CA_DIR%\rootCA.key" (
echo [i] Dung lai Root CA co san: %CA_DIR%\rootCA.crt
goto :server
)
> "%CA_DIR%\rootCA.cnf" (
echo [ req ]
echo distinguished_name = dn
echo prompt = no
echo x509_extensions = v3_ca
echo [ dn ]
echo C = VN
echo O = %ORG%
echo CN = %ORG% Root CA
echo [ v3_ca ]
echo basicConstraints = critical, CA:TRUE, pathlen:0
echo keyUsage = critical, keyCertSign, cRLSign
echo subjectKeyIdentifier = hash
echo authorityKeyIdentifier = keyid:always
)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "%CA_DIR%\rootCA.key" || goto :fail
openssl req -x509 -new -key "%CA_DIR%\rootCA.key" -sha256 -days %CA_DAYS% -config "%CA_DIR%\rootCA.cnf" -out "%CA_DIR%\rootCA.crt" || goto :fail
icacls "%CA_DIR%\rootCA.key" /inheritance:r /grant:r "%USERNAME%:(F)" >nul
echo [+] Da tao Root CA (%CA_DAYS% ngay)
:server
rem ---- 2) Chung chi server -----------------------------------------------------
> "%OUT_DIR%\server.cnf" (
echo [ req ]
echo distinguished_name = dn
echo prompt = no
echo [ dn ]
echo C = VN
echo O = %ORG%
echo CN = %IP%
)
> "%OUT_DIR%\server.ext" (
echo basicConstraints = critical, CA:FALSE
echo keyUsage = critical, digitalSignature, keyEncipherment
echo extendedKeyUsage = serverAuth
echo subjectKeyIdentifier = hash
echo authorityKeyIdentifier = keyid, issuer
echo subjectAltName = @alt_names
echo [ alt_names ]
echo DNS.1 = localhost
echo IP.1 = %IP%
echo IP.2 = 127.0.0.1
echo IP.3 = ::1
)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "%OUT_DIR%\server.key" || goto :fail
openssl req -new -key "%OUT_DIR%\server.key" -config "%OUT_DIR%\server.cnf" -out "%OUT_DIR%\server.csr" || goto :fail
openssl x509 -req -in "%OUT_DIR%\server.csr" -CA "%CA_DIR%\rootCA.crt" -CAkey "%CA_DIR%\rootCA.key" -CAserial "%CA_DIR%\rootCA.srl" -CAcreateserial -days %SERVER_DAYS% -sha256 -extfile "%OUT_DIR%\server.ext" -out "%OUT_DIR%\server.crt" || goto :fail
del "%OUT_DIR%\server.csr"
copy /y "%CA_DIR%\rootCA.crt" "%OUT_DIR%\rootCA.crt" >nul
rem ---- 3) Tu kiem tra ------------------------------------------------------------
openssl verify -CAfile "%CA_DIR%\rootCA.crt" -verify_ip %IP% "%OUT_DIR%\server.crt" || goto :fail
openssl x509 -in "%OUT_DIR%\server.crt" -noout -subject -enddate -ext subjectAltName
rem ---- 4) Tuy chon: cai Root CA vao Windows -----------------------------------
if /i "%~3"=="install" certutil -addstore -f Root "%CA_DIR%\rootCA.crt" || goto :fail
echo [OK] Xong. Server dung: %OUT_DIR%\server.key + server.crt
exit /b 0
:fail
echo [LOI] Lenh openssl/certutil that bai (errorlevel %errorlevel%)
exit /b 1
Chạy:
scripts\gen-certs.bat 192.168.1.2 825
scripts\gen-certs.bat 192.168.1.2 3650 install
Lưu ý khi sửa file
.bat: trong khối( ... )không được có dấu)chưa escape, nếu không cmd sẽ hiểu là kết thúc khối. Đó là lý do script dùnggoto :serverthay vìif ... elselồng nhau.
Tạo chứng chỉ thủ công (không dùng Script)
Mở cmd trong thư mục làm việc (ví dụ C:\PKI\DevRootCA). Mỗi bước có một lệnh, bạn có thể copy chạy từng dòng.
Bước 1 – Tạo file rootCA.cnf:
[ req ]
distinguished_name = dn
prompt = no
x509_extensions = v3_ca
[ dn ]
C = VN
O = FSTV Dev
CN = FSTV Dev Root CA
[ v3_ca ]
basicConstraints = critical, CA:TRUE, pathlen:0
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
Dùng file cấu hình riêng với
-configđể không phụ thuộc vàoopenssl.cnfmặc định – vị trí file này khác nhau giữa các bản build Windows.pathlen:0nghĩa là CA này chỉ được ký cert cuối (leaf), không ký thêm CA con.
Bước 2 – Khóa riêng và chứng chỉ Root CA (10 năm):
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out rootCA.key
openssl req -x509 -new -key rootCA.key -sha256 -days 3650 -config rootCA.cnf -out rootCA.crt
Bước 3 – Tạo server.cnf và server.ext:
server.cnf:
[ req ]
distinguished_name = dn
prompt = no
[ dn ]
C = VN
O = FSTV Dev
CN = 192.168.1.2
server.ext:
basicConstraints = critical, CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
subjectAltName = @alt_names
[ alt_names ]
DNS.1 = localhost
IP.1 = 192.168.1.2
IP.2 = 127.0.0.1
IP.3 = ::1
Bước 4 – Khóa riêng và CSR của server:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
openssl req -new -key server.key -config server.cnf -out server.csr
Bước 5 – CA ký cert server. Chọn một trong hai:
:: (A) 825 ngày – KHUYẾN NGHỊ khi có macOS/iOS (1–2 năm cũng được: 365 hoặc 730)
openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAserial rootCA.srl -CAcreateserial -days 825 -sha256 -extfile server.ext -out server.crt
:: (B) 10 năm – chỉ dùng khi client toàn Windows / Android
openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAserial rootCA.srl -CAcreateserial -days 3650 -sha256 -extfile server.ext -out server.crt
Bước 6 – Dọn dẹp và phân phối:
del server.csr
copy server.key C:\Projects\web-https-demo\certs\
copy server.crt C:\Projects\web-https-demo\certs\
copy rootCA.crt C:\Projects\web-https-demo\certs\
Không có copy rootCA.key – file này không bao giờ rời kho CA.
❗ Đừng dùng lệnh “một dòng” kiểu
openssl req -x509 -newkey ... -addext subjectAltName=...cho mọi trường hợp. Lệnh đó tạo cert tự ký đơn lẻ – mỗi lần đổi cert là mỗi lần client phải cài lại, và dễ dínhERR_CERT_AUTHORITY_INVALIDvì cert vừa là CA vừa là server.
Thời hạn: vì sao 825 ngày?
| Nền tảng | Giới hạn cert server do CA nội bộ cấp | 10 năm được không? |
|---|---|---|
| Chrome / Edge trên Windows | Không áp giới hạn 398 ngày (chỉ áp cho CA công khai) | ✅ |
| Firefox | Không áp cho CA do người dùng thêm | ✅ |
| Android (Chrome) | Không áp cho CA người dùng | ✅ |
| macOS / iOS / iPadOS | Cert cấp sau 01/07/2019 phải ≤ 825 ngày, RSA ≥ 2048, SHA-2, có SAN và serverAuth |
❌ → dùng 825 ngày |
| CA công khai (để so sánh) | Hiện ≤ 398 ngày và đang giảm dần theo lộ trình CA/B Forum | – |
Root CA 10 năm thì không vướng giới hạn nào ở trên.
Kiểm tra tính chính xác của các file chứng chỉ
Chạy trong thư mục chứa file (cmd hoặc PowerShell đều được).
| # | Kiểm tra | Lệnh |
|---|---|---|
| 1 | Phiên bản OpenSSL | openssl version |
| 2 | Root CA đúng là CA | openssl x509 -in rootCA.crt -noout -subject -issuer -dates -ext basicConstraints,keyUsage |
| 3 | SAN của cert server | openssl x509 -in server.crt -noout -ext subjectAltName |
| 4 | Cert server do Root CA ký | openssl verify -CAfile rootCA.crt server.crt |
| 5 | Cert khớp IP truy cập | openssl verify -CAfile rootCA.crt -verify_ip 192.168.1.2 server.crt |
| 6 | Cert dùng được cho server TLS | openssl verify -CAfile rootCA.crt -purpose sslserver server.crt |
| 7 | Khóa khớp cert | openssl x509 -noout -pubkey -in server.crt \| openssl sha256 và openssl pkey -in server.key -pubout \| openssl sha256 |
| 8 | Còn hạn ít nhất 30 ngày | openssl x509 -in server.crt -noout -checkend 2592000 |
| 9 | Thời hạn, thuật toán | openssl x509 -in server.crt -noout -dates -text \| findstr /C:"Signature Algorithm" /C:"Public-Key" |
| 10 | Server thật trả đúng cert | openssl s_client -connect 192.168.1.2:3443 -CAfile rootCA.crt -verify_ip 192.168.1.2 -brief |
Kết quả đúng
> openssl x509 -in rootCA.crt -noout -subject -issuer -dates -ext basicConstraints,keyUsage
subject=C = VN, O = FSTV Dev, CN = FSTV Dev Root CA
issuer=C = VN, O = FSTV Dev, CN = FSTV Dev Root CA <- tự ký: issuer = subject
notBefore=Nov 9 05:13:34 2026 GMT
notAfter=Nov 6 05:13:34 2036 GMT <- 10 năm
X509v3 Basic Constraints: critical
CA:TRUE, pathlen:0
X509v3 Key Usage: critical
Certificate Sign, CRL Sign
> openssl x509 -in server.crt -noout -ext subjectAltName
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:192.168.1.2, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1
> openssl verify -CAfile rootCA.crt -verify_ip 192.168.1.2 server.crt
server.crt: OK
> openssl verify -CAfile rootCA.crt -purpose sslserver server.crt
server.crt: OK
> openssl x509 -noout -pubkey -in server.crt | openssl sha256
SHA2-256(stdin)= d6747c61dac8714300f2efd87260d302f3131c6f513254ecbc5587368b9cc32a
> openssl pkey -in server.key -pubout | openssl sha256
SHA2-256(stdin)= d6747c61dac8714300f2efd87260d302f3131c6f513254ecbc5587368b9cc32a <- giống nhau
> openssl x509 -in server.crt -noout -checkend 2592000
Certificate will not expire
> openssl s_client -connect 192.168.1.2:3443 -CAfile rootCA.crt -verify_ip 192.168.1.2 -brief
CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: C = VN, O = FSTV Dev, CN = 192.168.1.2
Verification: OK
Kết quả sai (minh họa)
| Tình huống | Output | Ý nghĩa → Cách sửa |
|---|---|---|
Ký bằng CA khác / dùng nhầm rootCA.crt |
error 20 at 0 depth lookup: unable to get local issuer certificate |
Cert không do CA này ký → ký lại bằng đúng rootCA.key |
| Cert thiếu SAN | No extensions in certificate (hoặc không in ra dòng SAN nào) |
Quên -extfile server.ext → ký lại |
IP không khớp / IP ghi thành DNS:192.168.1.2 |
error 64 at 0 depth lookup: IP address mismatch |
Sửa [alt_names] thành IP.1 = ... → ký lại |
Thiếu extendedKeyUsage = serverAuth |
error 26 at 0 depth lookup: unsuitable certificate purpose |
Bổ sung trong server.ext |
| Khóa không khớp cert | Hai dòng SHA2-256(stdin)= khác nhau |
Copy nhầm server.key cũ → dùng đúng cặp |
| Cert sắp/đã hết hạn | Certificate will expire |
Cấp lại cert server |
| Server dùng cert khác / thiếu Root CA | verify error:num=20:unable to get local issuer certificate và num=21:unable to verify the first certificate |
Kiểm tra CERT_DIR server và tham số -CAfile |
| Gõ HTTPS vào cổng HTTP | curl: (52) Empty reply from server hoặc ERR_SSL_PROTOCOL_ERROR |
Dùng cổng 3443 |
Một phép thử thú vị chứng minh “CN không có giá trị”: cert có CN = 192.168.1.2 nhưng không có SAN vẫn trả error 64 ... IP address mismatch.
Dựng web Node.js chạy song song HTTP và HTTPS
Cấu trúc dự án:
web-https-demo\
├─ certs\ <- sinh bởi script, KHÔNG commit key
│ ├─ server.key
│ ├─ server.crt
│ └─ rootCA.crt <- chỉ để phát cho client
├─ scripts\
│ ├─ gen-certs.ps1
│ └─ gen-certs.bat
├─ server.js
├─ server-one-port.js <- Phụ lục A
├─ package.json
└─ .gitignore
package.json:
{
"name": "web-https-demo",
"version": "1.0.0",
"private": true,
"scripts": {
"certs": "powershell -ExecutionPolicy Bypass -File scripts/gen-certs.ps1 -ServerDays 825",
"start": "node server.js",
"start:one-port": "node server-one-port.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
.gitignore:
node_modules/
certs/*.key
certs/*.csr
# Khuyến nghị: bỏ qua cả thư mục certs, mỗi môi trường tự sinh
# certs/
server.js:
const fs = require('node:fs');
const path = require('node:path');
const http = require('node:http');
const https = require('node:https');
const express = require('express');
const HOST = process.env.HOST || '0.0.0.0';
const HTTP_PORT = Number(process.env.HTTP_PORT || 3000);
const HTTPS_PORT = Number(process.env.HTTPS_PORT || 3443);
const CERT_DIR = process.env.CERT_DIR || path.join(__dirname, 'certs');
const app = express();
app.get('/', (req, res) => {
res.type('html').send(`<h1>Xin chào qua ${req.protocol.toUpperCase()}</h1>
<p>Host: ${req.headers.host} – TLS: ${req.secure ? req.socket.getProtocol() : 'không'}</p>`);
});
app.get('/health', (req, res) => res.json({ ok: true, protocol: req.protocol }));
const tlsOptions = {
key: fs.readFileSync(path.join(CERT_DIR, 'server.key')),
cert: fs.readFileSync(path.join(CERT_DIR, 'server.crt')),
minVersion: 'TLSv1.2',
};
http.createServer(app).listen(HTTP_PORT, HOST, () =>
console.log(`HTTP : http://192.168.1.2:${HTTP_PORT}`));
https.createServer(tlsOptions, app).listen(HTTPS_PORT, HOST, () =>
console.log(`HTTPS : https://192.168.1.2:${HTTPS_PORT}`));
Một vài điểm đáng chú ý:
- Cùng một
appđược gắn vào cả hai server, nên route chỉ viết một lần. HOST = '0.0.0.0'để máy khác trong LAN truy cập được. Nếu đểlocalhostthì chỉ máy server vào được.- Cert ký trực tiếp bởi Root CA nên không cần tham số
ca(chain chỉ có một cert). Nếu sau này dùng CA trung gian, ghépserver.crt+intermediate.crtvào một filefullchain.crtvà trỏcerttới đó. - Muốn ép mọi người sang HTTPS, thay handler HTTP bằng redirect:
http.createServer((req, res) => {
const host = (req.headers.host || '192.168.1.2').split(':')[0];
res.writeHead(301, { Location: `https://${host}:${HTTPS_PORT}${req.url}` }).end();
}).listen(HTTP_PORT, HOST);
Mở firewall trên server (PowerShell Admin) và chạy:
New-NetFirewallRule -DisplayName "Node web-https-demo" -Direction Inbound -Protocol TCP -LocalPort 3000,3443 -Action Allow -Profile Private,Domain
npm install
npm start
HTTP : http://192.168.1.2:3000
HTTPS : https://192.168.1.2:3443
Kiểm tra nhanh từ một máy client:
Test-NetConnection 192.168.1.2 -Port 3443 # TcpTestSucceeded : True
curl.exe http://192.168.1.2:3000/health # {"ok":true,"protocol":"http"}
curl.exe https://192.168.1.2:3443/health # {"ok":true,"protocol":"https"} (sau khi cài Root CA)
Trên PowerShell hãy gõ curl.exe, vì curl là alias của Invoke-WebRequest. curl.exe của Windows dùng Schannel, tức là đọc luôn kho chứng chỉ Windows. Nếu chưa cài Root CA sẽ thấy:
curl: (60) schannel: SEC_E_UNTRUSTED_ROOT (0x80090325) - The certificate chain was issued by an authority that is not trusted.
Vị trí lưu trữ: code, server, client
| Nơi | Đường dẫn gợi ý | File | Quyền truy cập |
|---|---|---|---|
| Kho CA (máy quản trị, ngoài repo) | C:\PKI\DevRootCA\ |
rootCA.key, rootCA.crt, rootCA.cnf, rootCA.srl |
Chỉ quản trị viên (icacls /inheritance:r), backup mã hóa / USB offline |
| Mã nguồn | web-https-demo\certs\ |
server.crt, rootCA.crt (public); server.key bị .gitignore |
Dev |
| Mã nguồn | web-https-demo\scripts\ |
gen-certs.ps1, gen-certs.bat |
Commit bình thường |
| Server 192.168.1.2 | C:\apps\web-https-demo\certs\ hoặc C:\ProgramData\web-https-demo\certs\ + biến CERT_DIR |
server.key, server.crt |
server.key chỉ cho tài khoản chạy Node (service account) đọc |
| Client Windows | Cert:\LocalMachine\Root (Trusted Root Certification Authorities) |
rootCA.crt |
Admin cài cho mọi user; CurrentUser\Root cho riêng một user |
| Client macOS | /Library/Keychains/System.keychain |
rootCA.crt |
sudo |
| Client iOS/iPadOS | Cấu hình (Profile) + Certificate Trust Settings | rootCA.crt |
Người dùng bật Full Trust |
| Client Android | Encryption & credentials → User credentials | rootCA.crt |
Người dùng |
| Firefox | Kho NSS riêng, hoặc đọc kho hệ điều hành | rootCA.crt |
Người dùng |
| Linux | /usr/local/share/ca-certificates/ (Debian/Ubuntu) |
rootCA.crt |
root |
| Node.js client / script | Biến NODE_EXTRA_CA_CERTS |
rootCA.crt |
Theo tiến trình |
flowchart TB
subgraph V["Kho CA – C:\PKI\DevRootCA"]
RK["rootCA.key 🔐"]
RT["rootCA.crt"]
end
subgraph R["Repo – web-https-demo"]
RS["scripts/gen-certs.*"]
RC["certs/server.crt, rootCA.crt<br/>(server.key bị .gitignore)"]
end
subgraph S["Server 192.168.1.2"]
SK["certs/server.key 🔑"]
SC["certs/server.crt"]
end
subgraph C["Client"]
W["Windows: LocalMachine\Root"]
M["macOS/iOS: Keychain / Profile"]
A["Android / Firefox / Node"]
end
RS -- "sinh" --> RC
V -- "ký" --> SC
RC -- "deploy" --> S
RT -- "phân phối" --> W & M & A
Cài Root CA lên máy Client
Chỉ gửi rootCA.crt. Kiểm tra fingerprint trước khi cài để chắc không bị tráo file:
openssl x509 -in rootCA.crt -noout -fingerprint -sha256
certutil -hashfile rootCA.crt SHA256
| Nền tảng | Cách cài |
|---|---|
| Windows (mọi user) | certutil -addstore -f Root rootCA.crt (cmd Admin) hoặc Import-Certificate -FilePath rootCA.crt -CertStoreLocation Cert:\LocalMachine\Root |
| Windows (một user) | certutil -user -addstore Root rootCA.crt – Windows hiện hộp thoại xác nhận |
| Windows (nhiều máy trong domain) | GPO: Computer Configuration → Policies → Windows Settings → Security Settings → Public Key Policies → Trusted Root Certification Authorities |
| macOS | sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain rootCA.crt |
| iOS / iPadOS | Gửi file qua AirDrop/mail → Settings → General → VPN & Device Management cài profile → Settings → General → About → Certificate Trust Settings bật Full Trust |
| Android | Settings → Security → Encryption & credentials → Install a certificate → CA certificate (Chrome tin CA người dùng; app native từ Android 7 thì mặc định không) |
| Firefox | about:config → security.enterprise_roots.enabled = true để đọc kho Windows/macOS, hoặc Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import |
| Ubuntu/Debian | sudo cp rootCA.crt /usr/local/share/ca-certificates/fstv-dev-root-ca.crt && sudo update-ca-certificates (Chrome trên Linux dùng NSS: certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "FSTV Dev Root CA" -i rootCA.crt) |
| Node.js client | set NODE_EXTRA_CA_CERTS=C:\path\rootCA.crt trước khi chạy node (Node bản mới có thêm cờ --use-system-ca để đọc kho hệ điều hành) |
Kiểm tra đã cài trên Windows:
Get-ChildItem Cert:\LocalMachine\Root | Where-Object Subject -like '*FSTV Dev Root CA*' | Format-List Subject, NotAfter, Thumbprint
Sau khi cài, đóng hẳn trình duyệt (mọi cửa sổ, kể cả chạy nền) rồi mở lại.
Trình duyệt kiểm tra chứng chỉ như thế nào
Quá trình bắt tay TLS 1.3 rút gọn:
sequenceDiagram
autonumber
participant B as Trình duyệt
participant S as Node.js :3443
participant T as Kho Trusted Root (OS)
B->>S: ClientHello (TLS 1.3, cipher, key share)
S->>B: ServerHello + Certificate (server.crt) + CertificateVerify (ký bằng server.key)
B->>T: Tìm CA có Subject = Issuer của server.crt
T-->>B: rootCA.crt (nếu đã cài)
B->>B: Kiểm tra hạn, chữ ký, SAN = 192.168.1.2, serverAuth, chính sách
alt Tất cả đạt
B->>S: Finished → dữ liệu HTTP mã hóa 🔒
else Có bước sai
B-->>B: Trang cảnh báo NET::ERR_CERT_*
end
Thứ tự kiểm tra và lỗi tương ứng:
flowchart TD
A["Nhận server.crt"] --> B{"Còn hạn?"}
B -- Không --> E1["ERR_CERT_DATE_INVALID"]
B -- Có --> C{"Chuỗi tới Root<br/>trong kho tin cậy?"}
C -- Không --> E2["ERR_CERT_AUTHORITY_INVALID"]
C -- Có --> D{"Chữ ký hợp lệ?"}
D -- Không --> E3["ERR_CERT_INVALID"]
D -- Có --> F{"Host/IP nằm trong SAN?"}
F -- Không --> E4["ERR_CERT_COMMON_NAME_INVALID"]
F -- Có --> G{"serverAuth, CA:FALSE,<br/>nameConstraints?"}
G -- Không --> E5["ERR_CERT_INVALID /<br/>NAME_CONSTRAINT_VIOLATION"]
G -- Có --> H{"Chính sách nền tảng<br/>(Apple ≤ 825 ngày)?"}
H -- Không --> E6["ERR_CERT_VALIDITY_TOO_LONG"]
H -- Có --> OK["🔒 Kết nối an toàn"]
Về thu hồi (revocation): CA nội bộ trong bài không công bố CRL/OCSP. Với cert do CA người dùng tự thêm, Chrome/Edge không bắt buộc kiểm tra thu hồi nên vẫn hiện ổ khóa. Hệ quả là nếu server.key bị lộ thì không có cách “thu hồi” nhanh – phải cấp lại cert, và nếu rootCA.key lộ thì phải gỡ Root CA khỏi mọi client.
Kiểm thử và nghiệm thu
| # | Hạng mục | Cách kiểm tra | Kết quả mong đợi | Đạt |
|---|---|---|---|---|
| 1 | OpenSSL đúng bản | openssl version |
OpenSSL 3.5.x |
☐ |
| 2 | Root CA hợp lệ | openssl x509 -in rootCA.crt -noout -ext basicConstraints -enddate |
CA:TRUE, pathlen:0, hết hạn sau ~10 năm |
☐ |
| 3 | SAN đầy đủ | openssl x509 -in server.crt -noout -ext subjectAltName |
Có IP Address:192.168.1.2 và DNS:localhost |
☐ |
| 4 | Chain + IP | openssl verify -CAfile rootCA.crt -verify_ip 192.168.1.2 server.crt |
server.crt: OK |
☐ |
| 5 | Khóa khớp cert | So 2 hash SHA-256 | Giống nhau | ☐ |
| 6 | Thời hạn cert server | openssl x509 -in server.crt -noout -enddate |
≤ 825 ngày nếu có Apple, ≤ 10 năm nếu không | ☐ |
| 7 | Bí mật CA | Kiểm tra repo và server | Không có rootCA.key ngoài kho CA |
☐ |
| 8 | Git | git check-ignore certs/server.key |
In ra đường dẫn (đã bị ignore) | ☐ |
| 9 | Firewall | Test-NetConnection 192.168.1.2 -Port 3443 |
TcpTestSucceeded : True |
☐ |
| 10 | HTTP | Mở http://192.168.1.2:3000 |
Trang “Xin chào qua HTTP” | ☐ |
| 11 | HTTPS Chrome/Edge (Windows) | Mở https://192.168.1.2:3443 |
Ổ khóa, không cảnh báo | ☐ |
| 12 | HTTPS localhost (trên server) | Mở https://localhost:3443 |
Ổ khóa, không cảnh báo | ☐ |
| 13 | HTTPS Safari (macOS/iOS) | Mở https://192.168.1.2:3443 |
Ổ khóa (cert ≤ 825 ngày) | ☐ |
| 14 | HTTPS Android Chrome | Mở https://192.168.1.2:3443 |
Ổ khóa | ☐ |
| 15 | Firefox | Mở https://192.168.1.2:3443 |
Ổ khóa | ☐ |
| 16 | TLS tối thiểu | openssl s_client -connect 192.168.1.2:3443 -tls1_1 |
Bắt tay thất bại | ☐ |
| 17 | curl | curl.exe https://192.168.1.2:3443/health |
{"ok":true,"protocol":"https"} |
☐ |
| 18 | Truy cập sai IP (âm tính) | Mở https://<IP khác của server>:3443 |
Có cảnh báo COMMON_NAME_INVALID – đúng thiết kế |
☐ |
| 19 | Khởi động lại server | Reboot, chạy lại service | HTTP/HTTPS lên lại, cert không đổi | ☐ |
Mẹo xem chi tiết cert trên Chrome/Edge: bấm biểu tượng bên trái thanh địa chỉ → Connection is secure → Certificate is valid → tab Details để xem Subject Alternative Name và Certification Path.
Rủi ro và lưu ý
| Rủi ro | Mức độ | Hậu quả | Giảm thiểu |
|---|---|---|---|
Lộ rootCA.key |
🔴 Rất cao | Kẻ có file có thể giả mạo HTTPS của bất kỳ website (ngân hàng, email…) trên mọi máy đã tin CA | Lưu offline, ACL chặt, không commit, không copy lên server; dùng nameConstraints (Phụ lục B); khi lộ thì gỡ Root CA khỏi mọi client và làm CA mới |
Lộ server.key |
🟠 Cao | Giả mạo đúng server 192.168.1.2 |
ACL cho service account, không commit, cấp lại cert khi nghi ngờ |
| IP server thay đổi (DHCP) | 🟠 Cao | Toàn bộ client báo COMMON_NAME_INVALID |
Đặt IP tĩnh hoặc DHCP reservation; tốt hơn là thêm tên DNS nội bộ (DNS.2 = app.fstv.local) vào SAN |
| Cert server 10 năm trên Apple | 🟠 Trung bình | Safari/iOS không vào được | Dùng 825 ngày khi có thiết bị Apple |
| Cert dài hạn ít được thay | 🟡 Trung bình | Lộ khóa thì bị khai thác lâu dài; quên quy trình gia hạn | Ưu tiên 1–2 năm; ghi lịch nhắc trước 30 ngày; script -checkend |
| Root CA hết hạn (sau 10 năm) | 🟡 Thấp | Toàn bộ cert con mất hiệu lực cùng lúc | Ghi lịch, tạo CA mới trước 6–12 tháng, cài song song |
| Cài nhầm kho (Personal/Intermediate) | 🟡 Thấp | Vẫn AUTHORITY_INVALID |
Cài đúng Trusted Root Certification Authorities |
| Trùng serial khi ký lại | 🟡 Thấp | Firefox có thể báo SEC_ERROR_REUSED_ISSUER_AND_SERIAL |
Luôn dùng -CAserial rootCA.srl -CAcreateserial; không xóa rootCA.srl |
| HSTS trên IP/localhost | 🟡 Thấp | Bật helmet() gửi HSTS → trình duyệt nhớ, sau này không quay lại HTTP được |
Tắt HSTS cho môi trường nội bộ: helmet({ hsts: false }) |
| Nhiều bản OpenSSL trong PATH | 🟢 Thấp | Lệnh chạy bằng bản cũ (1.1.x) khác cú pháp | where openssl, script kiểm tra OpenSSL 3. |
| App Android native | 🟢 Thấp | App không tin CA người dùng | Khai báo network_security_config trong app |
BOM UTF-8 trong file .cnf |
🟢 Thấp | OpenSSL báo lỗi parse cấu hình | Lưu ASCII/UTF-8 không BOM (script dùng -Encoding ascii) |
Một số lưu ý thêm:
- Đây là giải pháp cho mạng nội bộ / dev / demo. Website công khai hãy dùng CA công khai (Let’s Encrypt…).
- Thêm
rootCA.crtvào kho tin cậy là quyết định tin tưởng. Chỉ cài vào máy mình quản lý, và nói rõ cho người dùng biết. - Nếu sau này cần nhiều server, nên tách thành Root CA (offline) → Intermediate CA (ký hằng ngày). Khi đó đổi
pathlen:0thànhpathlen:1và server phải gửi kèm cert intermediate.
Gia hạn và thay chứng chỉ
flowchart LR
A["Lịch nhắc<br/>trước 30 ngày"] --> B["gen-certs.ps1 -Force<br/>(giữ Root CA)"]
B --> C["Kiểm tra<br/>verify / SAN / hash"]
C --> D["Copy server.key + server.crt<br/>lên server"]
D --> E["Restart Node"]
E --> F["Nghiệm thu<br/>trình duyệt"]
Client không cần làm gì khi cấp lại cert server, vì Root CA không đổi. Để giám sát, có thể đặt lệnh sau vào Task Scheduler, mã thoát 1 nghĩa là cert sẽ hết hạn trong 30 ngày:
openssl x509 -in C:\apps\web-https-demo\certs\server.crt -noout -checkend 2592000
Phụ lục A: HTTP và HTTPS dùng chung một cổng
Hai server.listen() không thể cùng chiếm một cổng (EADDRINUSE). Nhưng ta có thể mở một server TCP, đọc byte đầu tiên mà client gửi rồi chuyển socket cho đúng bên xử lý: gói TLS luôn mở đầu bằng record type 0x16 (Handshake), còn HTTP mở đầu bằng chữ cái của method (G trong GET, P trong POST…).
flowchart LR
C["Client"] --> N["net.Server :3000"]
N --> P{"Byte đầu tiên<br/>= 0x16 ?"}
P -- "Có (TLS ClientHello)" --> HS["https.Server<br/>(không listen)"]
P -- "Không (GET, POST...)" --> H["http.Server<br/>(không listen)"]
HS --> APP["Express app"]
H --> APP
server-one-port.js:
const fs = require('node:fs');
const path = require('node:path');
const net = require('node:net');
const http = require('node:http');
const https = require('node:https');
const express = require('express');
const PORT = Number(process.env.PORT || 3000);
const app = express();
app.get('/', (req, res) => res.send(`Xin chào qua ${req.protocol.toUpperCase()} trên cổng ${PORT}`));
const tlsOptions = {
key: fs.readFileSync(path.join(__dirname, 'certs', 'server.key')),
cert: fs.readFileSync(path.join(__dirname, 'certs', 'server.crt')),
minVersion: 'TLSv1.2',
};
// Hai server "ảo": không gọi listen(), chỉ nhận socket được chuyển tới
const httpServer = http.createServer(app);
const httpsServer = https.createServer(tlsOptions, app);
// Server TCP duy nhất: đọc byte đầu tiên để biết client nói giao thức gì
net.createServer((socket) => {
socket.once('data', (chunk) => {
socket.pause();
socket.unshift(chunk); // trả byte đã đọc lại cho luồng
const isTls = chunk[0] === 0x16; // 0x16 = TLS Handshake record
(isTls ? httpsServer : httpServer).emit('connection', socket);
process.nextTick(() => socket.resume());
});
socket.on('error', () => socket.destroy());
}).listen(PORT, '0.0.0.0', () => console.log(`HTTP + HTTPS cùng cổng ${PORT}`));
Kết quả:
> curl.exe http://192.168.1.2:3000/
Xin chào qua HTTP trên cổng 3000
> curl.exe https://192.168.1.2:3000/
Xin chào qua HTTPS trên cổng 3000
| Hai cổng (3000 / 3443) | Một cổng (3000) | |
|---|---|---|
| Độ phức tạp | Thấp | Trung bình (tự xử lý socket) |
| Firewall | Mở 2 cổng | Mở 1 cổng |
| Debug, log, proxy | Dễ, công cụ hỗ trợ sẵn | Khó hơn; reverse proxy / load balancer thường không hiểu kiểu này |
| HTTP/2, keep-alive | Bình thường | Cần thử kỹ |
| Khi nào dùng | Mặc định | Môi trường chỉ cho mở đúng 1 cổng, thiết bị nhúng, demo |
Lưu ý: kỹ thuật này chỉ phân biệt TLS và HTTP thô. Với client “im lặng chờ server nói trước” (một số giao thức khác) socket sẽ treo, nên nên thêm socket.setTimeout() nếu đưa vào môi trường thật.
Phụ lục B: Giới hạn phạm vi Root CA bằng nameConstraints
Để giảm hậu quả khi lộ rootCA.key, thêm vào mục [ v3_ca ] của rootCA.cnf trước khi tạo CA:
nameConstraints = critical, permitted;IP:192.168.1.0/255.255.255.0, permitted;IP:127.0.0.1/255.255.255.255, permitted;IP:0:0:0:0:0:0:0:1/FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF, permitted;DNS:localhost
Khi đó CA này chỉ ký hợp lệ được cho dải 192.168.1.0/24, 127.0.0.1, ::1 và localhost. Cert cho google.com do CA này ký sẽ bị từ chối (NET::ERR_CERT_NAME_CONSTRAINT_VIOLATION). Kiểm tra:
> openssl x509 -in rootCA.crt -noout -ext nameConstraints
X509v3 Name Constraints: critical
Permitted:
IP:192.168.1.0/255.255.255.0
IP:127.0.0.1/255.255.255.255
IP:0:0:0:0:0:0:0:1/FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF
DNS:localhost
Đánh đổi: muốn thêm tên miền nội bộ (ví dụ *.fstv.local) thì phải khai báo trước (permitted;DNS:.fstv.local), nếu không phải làm lại CA. Hãy thử nghiệm trên đủ các trình duyệt mục tiêu trước khi áp dụng.
Tham khảo
- OpenSSL 3.5 Final Release – OpenSSL Library
- OpenSSL 3.5 will be the next LTS release
- OpenSSL 3.5 Series Release Notes
- openssl-x509(1), openssl-req(1), x509v3_config(5)
- Win32/Win64 OpenSSL – Shining Light Productions
- FireDaemon OpenSSL for Windows
- Requirements for trusted certificates in iOS 13 and macOS 10.15 – Apple
- Node.js – HTTPS, TLS
- Bài trước: Cấu hình SSL HTTPS cho Localhost trong Node.js (mkcert)
DevOps & Hạ tầng SSL, TLS, OpenSSL, HTTPS, Localhost, Nodejs, Express, Windows