Mã hóa bất đối xứng và chữ ký số: RSA, ECC, Ed25519
Tiếp đi: mã hóa bất đối xứng. shop-app sắp gửi webhook "order.shipped" cho 30 đối tác. Dùng HMAC thì phải đưa secret cho cả 30 bên. Có cách nào khác không?
Có: chữ ký số với cặp public/private key. shop-app giữ private key để ký; 30 đối tác chỉ cần public key để verify. Không ai trong số họ giả mạo được shop-app.
1. Bài toán
shop-app ── POST order.shipped ──> Partner 1
── POST order.shipped ──> Partner 2
... Partner 30
2. Vấn đề với HMAC ở đây
- Shared secret: bên nào verify được thì cũng ký được. Partner 7 bị hack là kẻ tấn công giả được webhook gửi cho 29 bên còn lại (nếu dùng chung secret).
- Mỗi partner một secret thì phải quản lý 30 secret, xoay 30 lần.
- Không có non-repudiation: khi tranh chấp, shop-app không chứng minh được "partner tự tạo message này".
3. Giải pháp: cặp khóa bất đối xứng
| Thao tác | Dùng key nào | Mục đích |
|---|---|---|
| Encrypt | Public key của người nhận | Chỉ người nhận (có private key) đọc được |
| Decrypt | Private key của người nhận | |
| Sign | Private key của người gửi | Chứng minh người gửi, không bị sửa |
| Verify | Public key của người gửi | Ai cũng verify được |
Hai họ thuật toán:
- RSA: dựa trên bài toán phân tích thừa số. Key lớn (2048–4096 bit). Mã hóa dùng padding OAEP, ký dùng PSS (hoặc PKCS#1 v1.5 cho JWT RS256). Không dùng RSA "trơn" không padding.
- ECC (Elliptic Curve): key nhỏ hơn nhiều cho cùng độ an toàn, ký nhanh. ECDSA P-256 (JWT ES256), Ed25519 (EdDSA, Java 15+, deterministic, khó dùng sai).
Asymmetric chậm hơn AES hàng nghìn lần và RSA chỉ mã hóa được vài trăm bytes. Thực tế dùng hybrid: asymmetric để trao đổi / bọc một AES key, AES-GCM cho dữ liệu. TLS làm đúng như vậy (bài TLS).
4. Code từng bước
Bước 1: Dependency: không cần, java.security.Signature có sẵn.
Bước 2: Sinh key và config
# Ed25519 private key (PKCS#8) và public key (X.509)
openssl genpkey -algorithm ed25519 -out webhook-signing.pem
openssl pkey -in webhook-signing.pem -pubout -out webhook-signing.pub.pem
# Public key gửi cho partner / công bố tại https://api.shop.example/.well-known/webhook-keys
shop:
webhook:
signing-key: ${WEBHOOK_SIGNING_KEY} # nội dung PKCS#8 Base64 (bỏ dòng BEGIN/END)
key-id: wh-2026-10
Bước 3: Code Java, ký ở shop-app:
@Component
public class WebhookSigner {
private final PrivateKey key;
private final String keyId;
public WebhookSigner(@Value("${shop.webhook.signing-key}") String pkcs8B64,
@Value("${shop.webhook.key-id}") String keyId) throws Exception {
this.key = KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(pkcs8B64)));
this.keyId = keyId;
}
public Map<String, String> sign(long ts, byte[] body) throws GeneralSecurityException {
Signature s = Signature.getInstance("Ed25519");
s.initSign(key);
s.update((ts + ".").getBytes(StandardCharsets.UTF_8));
s.update(body);
return Map.of("X-Key-Id", keyId,
"X-Timestamp", String.valueOf(ts),
"X-Signature", Base64.getEncoder().encodeToString(s.sign()));
}
}
Partner verify (cũng là Java):
PublicKey pub = KeyFactory.getInstance("Ed25519")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubB64)));
Signature v = Signature.getInstance("Ed25519");
v.initVerify(pub);
v.update((ts + ".").getBytes(StandardCharsets.UTF_8));
v.update(rawBody);
boolean ok = v.verify(Base64.getDecoder().decode(sigB64));
// + kiểm timestamp, idempotency như bài 13.2
Với RSA thay "Ed25519" bằng "RSASSA-PSS" (cần PSSParameterSpec) hoặc "SHA256withRSA"; ECDSA là "SHA256withECDSA".
Bước 4: Test bằng curl
printf '%s' "$TS.$BODY" > msg.bin
echo "$SIG" | base64 -d > sig.bin
openssl pkeyutl -verify -pubin -inkey webhook-signing.pub.pem -rawin -in msg.bin -sigfile sig.bin
# Signature Verified Successfully
5. Cách hoạt động
Ký: message ──hash──> digest ──[private key]──> signature
Verify: message ──hash──> digest ─┐
signature ──[public key]──┴─> khớp? true/false
Hybrid encryption (ví dụ partner gửi file nhạy cảm cho shop-app):
partner: aesKey = random 256 bit
ct = AES-GCM(aesKey, file)
wrapped = RSA-OAEP(shopPublicKey, aesKey) gửi (wrapped, iv, ct)
shop: aesKey = RSA-OAEP-decrypt(shopPrivateKey, wrapped)
file = AES-GCM-decrypt(aesKey, ct)
6. So sánh
| Độ an toàn tương đương | Symmetric | RSA | ECC |
|---|---|---|---|
| ~112 bit | - | 2048 | 224 |
| ~128 bit | AES-128 | 3072 | 256 (P-256, Ed25519) |
| ~192 bit | AES-192 | 7680 | 384 |
| ~256 bit | AES-256 | 15360 | 521 |
| JWT alg | Loại | Key | Ghi chú |
|---|---|---|---|
| HS256 | HMAC | Shared secret ≥ 256 bit | Một service vừa phát vừa verify |
| RS256 | RSA PKCS#1 v1.5 + SHA-256 | RSA 2048+ | Phổ biến nhất, Keycloak mặc định; chữ ký 256 bytes |
| PS256 | RSA-PSS | RSA 2048+ | Padding hiện đại hơn RS256 |
| ES256 | ECDSA P-256 | EC 256 bit | Token ngắn hơn (chữ ký 64 bytes), ký nhanh |
| EdDSA | Ed25519 | 32 bytes | Nhanh, an toàn, hỗ trợ thư viện còn ít hơn |
Cấu hình JWT trong Spring (JwtEncoder/Decoder, JWKS) xem bài JWT.
7. Khi nào nên dùng / không nên dùng
- Chữ ký số: nhiều bên verify, cần non-repudiation, JWT phát bởi auth server cho nhiều resource server.
- Ed25519 / ES256: hệ thống mới. RSA 2048+: khi cần tương thích rộng.
- Không mã hóa dữ liệu lớn bằng RSA trực tiếp; dùng hybrid. Không dùng RSA 1024.
- Nếu chỉ hai bên tin nhau và một bên vừa ký vừa verify, HMAC đơn giản và nhanh hơn.
Mình hay nghe "chữ ký là mã hóa hash bằng private key". Vậy ký và mã hóa là một, chỉ đổi chiều key thôi đúng không?
Cách hiểu đó chỉ gần đúng với RSA trong sách giáo khoa, và dễ gây hiểu lầm:
- Với RSA, phép toán lõi đối xứng nhau, nhưng padding khác nhau: OAEP cho mã hóa, PSS/PKCS#1 v1.5 cho ký. Dùng lẫn là lỗ hổng. Không dùng chung một cặp key RSA cho cả ký và mã hóa.
- ECDSA và Ed25519 không có thao tác "mã hóa" nào cả, chỉ có ký và verify.
- "Mã hóa bằng private key" không tạo bí mật: ai cũng có public key để "giải".
Nên nói: chữ ký là một phép toán riêng dùng private key, ai có public key đều verify được. Mã hóa nhằm bí mật; chữ ký nhằm xác thực.
Mình tự ký JWT ES256 bằng Signature.getInstance("SHA256withECDSA") rồi ghép header.payload.signature. Thư viện bên partner báo invalid signature, dù key đúng. Sao vậy?
Lỗi định dạng chữ ký kinh điển. Java trả chữ ký ECDSA ở dạng DER (ASN.1, khoảng 70–72 bytes), còn JWS yêu cầu dạng raw R||S đúng 64 bytes cho ES256.
Java SHA256withECDSA: 30 45 02 20 <R 32B> 02 21 00 <S 32B> (DER)
JWS ES256 cần: <R 32B><S 32B> (raw, 64B)
Cách sửa:
- Dùng
"SHA256withECDSAinP1363Format"(Java 9+): trả thẳng dạng raw. - Tốt hơn: dùng thư viện JOSE (Nimbus, có sẵn trong Spring Security) qua
JwtEncoder. Đừng tự ghép JWT.
Phỏng vấn hỏi về mã hóa bất đối xứng và chữ ký số thì trả lời sao?
Q1. Mã hóa đối xứng và bất đối xứng khác nhau thế nào, dùng khi nào?
🇻🇳 Tiếng Việt:
Đối xứng như AES dùng một key cho cả hai chiều, rất nhanh, nhưng phải chia sẻ key an toàn. Bất đối xứng như RSA, ECC có cặp public và private key, giải quyết việc trao đổi key và chữ ký số nhưng chậm và giới hạn kích thước dữ liệu. Thực tế dùng hybrid: bất đối xứng để thỏa thuận hoặc bọc AES key, AES-GCM cho dữ liệu, giống TLS.
🇬🇧 English:
Symmetric encryption such as AES uses one key for both directions and is very fast, but the key must be shared securely. Asymmetric crypto such as RSA or ECC uses a public/private key pair, which solves key exchange and enables digital signatures, but it is slow and limited in data size. In practice we use hybrid encryption: asymmetric to agree on or wrap an AES key, and AES-GCM for the data, just like TLS.
Q2. RS256 và HS256 cho JWT khác gì, chọn cái nào?
🇻🇳 Tiếng Việt:
HS256 là HMAC với shared secret, ai verify được thì cũng ký được, hợp khi một service vừa phát vừa kiểm token. RS256 hay ES256 ký bằng private key ở auth server, resource server chỉ cần public key qua JWKS nên không thể giả token. Với nhiều service hoặc dùng Keycloak, em chọn RS256 hoặc ES256.
🇬🇧 English:
HS256 is HMAC with a shared secret, so anyone who can verify can also sign; it fits when one service both issues and validates tokens. RS256 or ES256 signs with a private key on the auth server, and resource servers only need the public key from JWKS, so they cannot forge tokens. With many services or an identity provider like Keycloak, I choose RS256 or ES256.
Q3. RSA hay ECC, key size bao nhiêu?
🇻🇳 Tiếng Việt:
RSA tối thiểu 2048 bit, 3072 nếu cần dùng lâu dài. ECC đạt độ an toàn tương đương với key nhỏ hơn nhiều: P-256 hay Ed25519 tương đương RSA 3072, ký nhanh và chữ ký ngắn. Hệ thống mới em ưu tiên Ed25519 hoặc ES256, giữ RSA khi cần tương thích.
🇬🇧 English:
RSA should be at least 2048 bits, and 3072 for long-term use. ECC reaches the same strength with much smaller keys: P-256 or Ed25519 roughly match RSA-3072, with faster signing and shorter signatures. For new systems I prefer Ed25519 or ES256 and keep RSA where compatibility requires it.
Cách nhớ ngắn gọn
- Encrypt bằng public key người nhận; ký bằng private key người gửi.
- Asymmetric chậm → hybrid với AES.
- RSA: OAEP để mã hóa, PSS để ký, ≥ 2048 bit. ECC 256 ≈ RSA 3072.
- JWT ES256 cần chữ ký raw 64 bytes, không phải DER; dùng thư viện JOSE.
Tự kiểm tra
Để gửi dữ liệu bí mật cho partner, dùng public key của ai?
Public key của partner (người nhận). Chỉ partner có private key giải mã được.
Vì sao không mã hóa file 10 MB trực tiếp bằng RSA?
RSA chỉ mã hóa được dữ liệu nhỏ hơn kích thước key trừ padding, và rất chậm. Dùng hybrid: AES-GCM cho file, RSA-OAEP bọc AES key.
Chữ ký số cho thêm gì so với HMAC?
Người verify không ký được (tách quyền), và non-repudiation.
Ed25519 có thao tác encrypt không?
Không, chỉ ký và verify. Trao đổi key dùng X25519.
Java ký ECDSA ra định dạng gì, JWS cần định dạng gì?
Java mặc định DER; JWS cần raw R||S. Dùng SHA256withECDSAinP1363Format hoặc thư viện JOSE.