TLS/HTTPS: khóa đường truyền cho api.shop.example
Domain api.shop.example trỏ về VM rồi. Giờ làm HTTPS nhé.
Bài trước (DNS) ta đã có api.shop.example trỏ về public IP của Azure VM chạy order-service port 8080. Giờ ta khóa đường truyền bằng TLS.
1. Bài toán: client gọi API bằng HTTP thường
curl http://api.shop.example:8080/orders/42
Authorization: Bearer eyJhbGciOi...
Request đi qua Wi-Fi quán cà phê, router nhà mạng, nhiều hop trên Internet. Tất cả là plain text.
2. Vấn đề
- Nghe lén: ai đứng giữa cũng đọc được token, dữ liệu đơn hàng.
- Sửa nội dung: kẻ giữa đường đổi body/response mà client không biết.
- Giả mạo server: DNS bị đầu độc, client gửi request tới server giả.
- Browser đánh dấu "Not secure", nhiều API/OAuth provider bắt buộc HTTPS.
3. Giải pháp: HTTPS = HTTP chạy trên TLS
| TLS cho ta | Bằng cách nào |
|---|---|
| Confidentiality (bí mật) | Mã hóa đối xứng (AES-GCM, ChaCha20) bằng session key |
| Integrity (toàn vẹn) | AEAD: sửa 1 byte là giải mã fail |
| Authentication (đúng server) | Certificate do CA ký, khớp domain |
Certificate chứa: domain (SAN), public key của server, hạn dùng, chữ ký của CA. CA chain:
Root CA (có sẵn trong OS/JDK truststore)
└── ký Intermediate CA
└── ký Leaf cert: CN/SAN = api.shop.example
Client tin Root → kiểm chữ ký từng cấp → tin leaf. Server phải gửi leaf + intermediate (fullchain), không chỉ leaf.
4. Code từng bước
Bước 1: Cài Nginx và Certbot trên VM Ubuntu (Nginx chi tiết ở bài 9.8)
sudo apt update
sudo apt install -y nginx certbot python3-certbot-nginx
# NSG của Azure: mở inbound 80 và 443, đóng 8080 với Internet
Bước 2: Xin cert Let's Encrypt (miễn phí, hạn 90 ngày, tự gia hạn)
sudo certbot --nginx -d api.shop.example
# Let's Encrypt kiểm tra bạn sở hữu domain qua HTTP-01 challenge trên port 80
sudo certbot renew --dry-run # kiểm tra auto-renew (systemd timer)
Bước 3: Cấu hình Nginx terminate TLS
server {
listen 443 ssl;
http2 on;
server_name api.shop.example;
ssl_certificate /etc/letsencrypt/live/api.shop.example/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.shop.example/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-Proto https;
}
}
server {
listen 80;
server_name api.shop.example;
return 301 https://$host$request_uri;
}
Spring Boot cần biết request gốc là HTTPS (để tạo redirect/link đúng):
server:
port: 8080
address: 127.0.0.1 # chỉ nghe localhost, Internet không vào thẳng
forward-headers-strategy: native
Bước 4: Test
curl -v https://api.shop.example/orders/42
# * SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384
# * subject: CN=api.shop.example
# * issuer: C=US; O=Let's Encrypt; CN=...
openssl s_client -connect api.shop.example:443 -servername api.shop.example -showcerts
5. Cách hoạt động: TLS 1.3 handshake (rút gọn)
Client Nginx :443
| --- ClientHello ----------------------> | SNI=api.shop.example,
| | cipher list, key share (ECDHE)
| <-- ServerHello + key share ----------- |
| <-- Certificate (leaf+intermediate) --- |
| <-- CertificateVerify, Finished ------- |
| [client kiểm: chain → Root tin cậy, |
| SAN khớp domain, còn hạn] |
| --- Finished --------------------------> |
| Hai bên có cùng session key (ECDHE) |
| === HTTP request mã hóa AES-GCM ======> |
| | --- HTTP thường --> 127.0.0.1:8080
Ý chính: bất đối xứng (cert, ECDHE) chỉ dùng lúc handshake để thỏa thuận key; dữ liệu thật dùng đối xứng vì nhanh. TLS 1.3 chỉ cần 1 round-trip; ECDHE cho forward secrecy (lộ private key sau này cũng không giải mã được traffic cũ).
6. So sánh: terminate TLS ở đâu?
| Kiểu | Luồng | Ưu | Nhược |
|---|---|---|---|
| Termination tại Nginx/LB | Client =TLS=> Nginx -HTTP-> app | Một chỗ quản lý cert, app đơn giản | Đoạn sau proxy là plain text |
| Re-encrypt (TLS bridging) | Client =TLS=> LB =TLS=> app | LB vẫn đọc được L7, nội bộ vẫn mã hóa | Thêm cert ở app, tốn CPU |
| Passthrough / end-to-end | Client =TLS (qua LB L4)=> app | Chỉ app thấy plain text | LB không route theo path/header được |
7. Khi nào nên dùng / không nên dùng
- Termination ở Nginx: hợp với 1 VM, Nginx và app cùng máy (đoạn localhost).
- Khi app ở VM khác (2 instance sau LB, bài 9.7) hoặc có yêu cầu compliance (PCI, zero-trust): re-encrypt hoặc end-to-end.
- Không tự ký cert cho public API. Self-signed chỉ dùng nội bộ, và phải đưa CA vào truststore.
order-service gọi sang một API nội bộ dùng cert tự ký thì Java báo lỗi PKIX. Vì sao?order-service gọi sang payment nội bộ bị lỗi "PKIX path building failed", curl thì vẫn chạy với -k. Sao vậy?
Đúng rồi, curl -k là tắt kiểm tra cert, nên nó "chạy" chẳng nói lên gì. Java thì kiểm tra nghiêm túc:
javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException:
unable to find valid certification path to requested target
Nghĩa là: JVM không xây được chuỗi từ cert server lên một Root CA có trong truststore của nó (mặc định $JAVA_HOME/lib/security/cacerts).
Nguyên nhân thường gặp
- Server dùng cert tự ký / CA nội bộ của công ty.
- Server chỉ gửi leaf, thiếu intermediate (browser đôi khi tự tải bù, Java thì không).
- Proxy công ty chặn và ký lại TLS (SSL inspection).
- JDK cũ, cacerts thiếu Root mới.
Cách sửa đúng
# 1. Kiểm tra server gửi gì
openssl s_client -connect payment.internal:443 -showcerts
# 2. Tạo truststore riêng chứa CA nội bộ (password đọc từ biến môi trường)
keytool -importcert -alias shop-internal-ca -file internal-ca.crt \
-keystore /opt/order-service/truststore.p12 -storetype PKCS12 \
-storepass "$TRUSTSTORE_PASSWORD" -noprompt
Spring Boot 3.1+ có SSL bundle:
spring:
ssl:
bundle:
jks:
internal:
truststore:
location: file:/opt/order-service/truststore.p12
password: ${TRUSTSTORE_PASSWORD}
type: PKCS12
@Bean
RestClient paymentClient(RestClient.Builder builder, SslBundles bundles) {
var settings = ClientHttpRequestFactorySettings.defaults()
.withSslBundle(bundles.getBundle("internal")); // Boot 3.4+
return builder
.baseUrl("https://payment.internal")
.requestFactory(ClientHttpRequestFactoryBuilder.detect().build(settings))
.build();
}
Hoặc chỉ cấu hình property (Boot 3.4+): spring.http.client.ssl.bundle=internal. Hoặc toàn JVM: -Djavax.net.ssl.trustStore=... (cách này thay cacerts mặc định, nên các Root public cũng phải có trong file).
Tuyệt đối không viết
TrustManager"trust all" hay tắt hostname verification để cho chạy. Đó là mở cửa cho man-in-the-middle.
Phân biệt nhanh: keystore = cert + private key của mình (để chứng minh mình là ai). truststore = các CA mình tin (để kiểm người kia).
mTLS là gì? Có nên dùng thay JWT không?
Bạn hỏi đúng chỗ. mTLS (mutual TLS): trong handshake, server gửi thêm CertificateRequest, client phải gửi cert của nó và chứng minh có private key. Hai chiều đều xác thực.
order-service (client cert: CN=order-service)
==mTLS==> payment-service (chỉ nhận cert do CA nội bộ ký)
# Nginx phía payment bật mTLS
ssl_client_certificate /etc/nginx/internal-ca.crt;
ssl_verify_client on;
mTLS xác thực máy/service ở tầng transport. JWT mang danh tính người dùng và quyền ở tầng ứng dụng. Chúng bổ sung cho nhau, không thay nhau. Trong Kubernetes, service mesh (Istio, Linkerd) bật mTLS tự động và tự xoay cert, đỡ phải quản lý tay.
Phỏng vấn hỏi về HTTPS/TLS thì trả lời sao?
Q1. TLS handshake diễn ra thế nào?
🇻🇳 Tiếng Việt:
Client gửi ClientHello với SNI, cipher hỗ trợ và key share. Server trả ServerHello, certificate chain và chữ ký. Client kiểm chain về Root CA tin cậy, domain và hạn dùng. Hai bên dùng ECDHE tạo session key chung, rồi mã hóa dữ liệu bằng thuật toán đối xứng. TLS 1.3 chỉ cần một round-trip.
🇬🇧 English:
The client sends a ClientHello with SNI, supported ciphers and a key share. The server replies with a ServerHello, its certificate chain and a signature. The client validates the chain up to a trusted root, the hostname and the validity period. Both sides derive a shared session key via ECDHE, and the data is then encrypted with a symmetric cipher. TLS 1.3 needs only one round trip.
Q2. Bạn terminate TLS ở đâu?
🇻🇳 Tiếng Việt:
Với order-service em terminate ở Nginx bằng cert Let's Encrypt, Nginx proxy sang app qua localhost và gửi X-Forwarded-Proto. Khi app nằm ở VM khác hoặc có yêu cầu bảo mật cao, em re-encrypt từ load balancer tới backend hoặc dùng mTLS.
🇬🇧 English:
For order-service I terminate TLS at Nginx with a Let's Encrypt certificate, and Nginx proxies to the app over localhost with X-Forwarded-Proto. When the backend runs on another VM or compliance requires it, I re-encrypt from the load balancer to the backend or use mTLS.
Q3. Gặp lỗi "PKIX path building failed" thì xử lý thế nào?
🇻🇳 Tiếng Việt:
JVM không tìm được chain tới CA nó tin. Em kiểm bằng openssl s_client xem server có gửi đủ intermediate không. Nếu là CA nội bộ, em import CA vào một truststore riêng và cấu hình qua SSL bundle. Không bao giờ dùng trust-all.
🇬🇧 English:
It means the JVM cannot build a chain to a trusted CA. I check with openssl s_client whether the server sends the intermediate certificates. For an internal CA, I import it into a dedicated truststore and wire it through a Spring Boot SSL bundle. I never use a trust-all TrustManager.
Cách nhớ ngắn gọn
- TLS = bí mật + toàn vẹn + đúng server.
- Handshake: bất đối xứng để thỏa thuận key, đối xứng để truyền data.
- Keystore = mình là ai. Truststore = mình tin ai.
- PKIX = thiếu chain/CA trong truststore, sửa truststore chứ không tắt verify.
- mTLS = xác thực service; JWT = xác thực user.
Tự kiểm tra
Vì sao TLS không mã hóa toàn bộ dữ liệu bằng RSA/ECDSA?
Mã hóa bất đối xứng chậm. Nó chỉ dùng để xác thực và thỏa thuận session key; dữ liệu dùng AES-GCM/ChaCha20.
Server chỉ gửi leaf cert thì sao?
Client không có intermediate sẽ không dựng được chain. Java báo PKIX. Phải cấu hình fullchain.pem.
Let's Encrypt chứng minh bạn sở hữu domain thế nào?
Qua challenge, ví dụ HTTP-01: Certbot đặt file ở /.well-known/acme-challenge/ trên port 80, Let's Encrypt gọi tới để kiểm tra.
Terminate TLS ở Nginx thì Spring Boot cần cấu hình gì?
server.forward-headers-strategy để đọc X-Forwarded-Proto/For, và bind app vào 127.0.0.1 hoặc chặn 8080 bằng NSG.
Keystore khác truststore chỗ nào?
Keystore chứa cert + private key của mình. Truststore chứa các CA mình tin để kiểm cert phía bên kia.