TLS/HTTPS: khóa đường truyền cho api.shop.example

4 câu hỏi~25 phútTừ khóa: TLS handshake, certificate, CA chain, Let's Encrypt, TLS termination, PKIX, truststore, mTLS

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 đề

3. Giải pháp: HTTPS = HTTP chạy trên TLS

TLS cho taBằ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ểuLuồngƯuNhược
Termination tại Nginx/LBClient =TLS=> Nginx -HTTP-> appMộ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=> appLB vẫn đọc được L7, nội bộ vẫn mã hóaThêm cert ở app, tốn CPU
Passthrough / end-to-endClient =TLS (qua LB L4)=> appChỉ app thấy plain textLB không route theo path/header được

7. Khi nào nên dùng / không nên dùng

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

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


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.