Hash và HMAC: verify webhook thanh toán
Tiếp đi: hash và HMAC. Payment provider gọi webhook /webhooks/payment của shop-app báo đơn đã thanh toán. Làm sao biết request đó thật sự từ provider?
Đây là chỗ HMAC phát huy tác dụng. Trước hết xem hash có tính chất gì, rồi vì sao hash một mình không đủ.
1. Bài toán
Payment Provider ── POST /webhooks/payment ──> shop-app
{"orderId":42,"status":"PAID","amount":990000}
shop-app nhận được thì đánh dấu đơn 42 đã trả tiền và giao hàng.
2. Vấn đề
- Endpoint public. Ai cũng gửi được
curl -d '{"orderId":42,"status":"PAID"}'và nhận hàng miễn phí. - Thêm SHA-256 của body vào header cũng vô ích: kẻ giả mạo tự tính SHA-256 được, vì hash không có key.
- Kẻ tấn công còn có thể chụp một webhook thật rồi gửi lại nhiều lần (replay).
3. Giải pháp: hash có tính chất gì, HMAC thêm gì
Hash mật mã (SHA-256) có ba tính chất:
| Tính chất | Ý nghĩa |
|---|---|
| Preimage resistance | Có hash, không tìm được input |
| Second-preimage resistance | Có input A, không tìm được B khác A cùng hash |
| Collision resistance | Không tìm được cặp A, B bất kỳ cùng hash |
Thêm: output cố định (256 bit), đổi 1 bit input thì output đổi hoàn toàn (avalanche), deterministic.
MD5 và SHA-1 đã bị phá collision resistance: MD5 tạo collision trong vài giây; SHA-1 bị Google công bố collision (SHAttered, 2017). Không dùng cho chữ ký, certificate, toàn vẹn dữ liệu. MD5 chỉ còn dùng làm checksum không bảo mật (ETag, phân vùng dữ liệu).
HMAC (Hash-based Message Authentication Code) = hash + secret key chung giữa provider và shop-app:
tag = HMAC-SHA256(secret, timestamp + "." + body)
Không có secret thì không tính được tag đúng. Provider gửi tag trong header, shop-app tính lại và so sánh.
4. Code từng bước
Bước 1: Dependency: spring-boot-starter-web; HMAC có sẵn trong javax.crypto.Mac.
Bước 2: Config
payment:
webhook:
secret: ${PAYMENT_WEBHOOK_SECRET}
tolerance-seconds: 300
Bước 3: Code Java. Phải đọc raw body (bytes gốc), không parse JSON rồi serialize lại, vì đổi khoảng trắng/thứ tự field là tag sai.
@RestController
@RequestMapping("/webhooks/payment")
public class PaymentWebhookController {
private final byte[] secret;
private final long toleranceSec;
private final ObjectMapper mapper;
private final OrderService orders;
public PaymentWebhookController(
@Value("${payment.webhook.secret}") String secret,
@Value("${payment.webhook.tolerance-seconds}") long toleranceSec,
ObjectMapper mapper, OrderService orders) {
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.toleranceSec = toleranceSec;
this.mapper = mapper;
this.orders = orders;
}
@PostMapping
public ResponseEntity<Void> receive(@RequestHeader("X-Timestamp") long ts,
@RequestHeader("X-Signature") String sigHex,
@RequestBody byte[] rawBody) throws Exception {
// 1. Chống replay: timestamp phải gần hiện tại
long now = Instant.now().getEpochSecond();
if (Math.abs(now - ts) > toleranceSec) {
return ResponseEntity.status(401).build();
}
// 2. Tính lại HMAC trên đúng bytes đã nhận
Mac mac = Mac.getInstance("HmacSHA256"); // Mac không thread-safe: tạo mới mỗi lần
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
mac.update((ts + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = mac.doFinal(rawBody);
// 3. So sánh constant-time
byte[] received = HexFormat.of().parseHex(sigHex);
if (!MessageDigest.isEqual(expected, received)) {
return ResponseEntity.status(401).build();
}
// 4. Chỉ parse JSON sau khi đã verify; xử lý idempotent theo eventId
PaymentEvent event = mapper.readValue(rawBody, PaymentEvent.class);
orders.markPaidOnce(event.eventId(), event.orderId(), event.amount());
return ResponseEntity.ok().build();
}
}
HexFormat.parseHex ném IllegalArgumentException nếu header không phải hex; bắt lại và trả 401 trong code thật.
Bước 4: Test bằng curl
BODY='{"eventId":"evt_1","orderId":42,"status":"PAID","amount":990000}'
TS=$(date +%s)
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$PAYMENT_WEBHOOK_SECRET" -hex | awk '{print $2}')
curl -i -X POST http://localhost:8080/webhooks/payment \
-H "Content-Type: application/json" -H "X-Timestamp: $TS" -H "X-Signature: $SIG" \
-d "$BODY"
# 200
curl -i ... -H "X-Signature: 00ff..." # 401
# Gửi lại đúng request sau 10 phút # 401 (timestamp quá cũ)
5. Cách hoạt động
Provider (có secret) shop-app (có secret)
body, ts nhận body, ts, sig
sig = HMAC(secret, ts.body) ── HTTP ──> |ts - now| <= 300s ?
expected = HMAC(secret, ts.body)
isEqual(expected, sig) ?
eventId đã xử lý chưa? (idempotent)
HMAC bên trong (RFC 2104):
HMAC(K, m) = H( (K xor opad) || H( (K xor ipad) || m ) )
hash 2 lớp -> không bị length extension
Vì sao constant-time compare? Arrays.equals hay String.equals dừng ngay ở byte sai đầu tiên. Đo thời gian phản hồi, kẻ tấn công đoán từng byte của tag (timing attack). MessageDigest.isEqual so hết mọi byte, thời gian không phụ thuộc vị trí sai.
6. So sánh
| SHA-256(body) | SHA-256(secret + body) | HMAC-SHA256(secret, body) | Chữ ký RSA/ECDSA | |
|---|---|---|---|---|
| Cần key | Không | Có | Có (shared) | Private key ký, public key verify |
| Chống giả mạo | Không | Lỗi length extension | Có | Có |
| Non-repudiation | Không | Không | Không (hai bên cùng key) | Có |
| Tốc độ | Rất nhanh | Rất nhanh | Rất nhanh | Chậm hơn |
7. Khi nào nên dùng / không nên dùng
- SHA-256: checksum file, content-addressing, hash token entropy cao. Không dùng cho password (bài 13.3).
- HMAC: webhook, chữ ký request giữa hai bên tin nhau, blind index, JWT HS256 khi issuer và verifier là một.
- Chữ ký số (bài 13.5): khi nhiều bên verify mà không được phép ký.
- Không dùng MD5/SHA-1 cho bất cứ gì liên quan bảo mật.
SHA-256(secret + body) lại hỏng? Không phải có secret rồi sao?Mình thấy code cũ tính chữ ký kiểu sha256(secret + body). Có secret rồi thì sao vẫn gọi là không an toàn?
Do length extension attack. SHA-256 (và MD5, SHA-1, SHA-512) dùng cấu trúc Merkle–Damgård: output chính là trạng thái nội bộ sau khi xử lý block cuối.
Biết: h = SHA256(secret || body) và độ dài secret || body
Không biết: secret
Kẻ tấn công lấy h làm trạng thái khởi đầu, "ăn tiếp" dữ liệu:
h' = SHA256(secret || body || padding || "&amount=1")
mà không cần secret.
Tức là kẻ tấn công nối thêm dữ liệu vào cuối và vẫn tạo được chữ ký hợp lệ. HMAC tránh được nhờ hash hai lớp với khóa bọc ngoài. SHA-3 và BLAKE2 cũng không bị lỗi này, nhưng cách chuẩn và dễ nhất trong Java vẫn là HmacSHA256.
Chữ ký đúng rồi thì xử lý luôn được chưa? Provider nói có thể gửi lại cùng webhook nhiều lần nếu mình trả lỗi.
Chưa đủ. Chữ ký chỉ chứng minh ai gửi và không bị sửa, không chứng minh chỉ gửi một lần. Cần thêm:
- Timestamp nằm trong phần được ký, kiểm tra cửa sổ ±5 phút: chặn replay cũ.
- Idempotency theo
eventId: lưu bảngprocessed_webhook(event_id PK), insert trong cùng transaction với update đơn. Trùng PK thì bỏ qua và vẫn trả 200. - Đối chiếu số tiền với đơn trong DB, không tin hoàn toàn
amounttrong payload.
Mô hình chi tiết xem bài Outbox, Inbox.
Phỏng vấn hỏi về hash, HMAC và verify webhook thì trả lời sao?
Q1. HMAC khác hash thường ở đâu?
🇻🇳 Tiếng Việt:
Hash không có key nên ai cũng tính lại được, chỉ phát hiện lỗi ngẫu nhiên. HMAC trộn secret key vào theo cấu trúc hai lớp, nên chỉ ai có key mới tạo được tag đúng. HMAC chứng minh toàn vẹn và nguồn gốc, và không bị length extension như kiểu
hash(secret + data).
🇬🇧 English:
A plain hash has no key, so anyone can recompute it; it only detects accidental changes. HMAC mixes a secret key in with a two-layer construction, so only key holders can produce a valid tag. It provides integrity and authenticity, and unlike
hash(secret + data)it is not vulnerable to length extension.
Q2. Em verify webhook như thế nào?
🇻🇳 Tiếng Việt:
Em đọc raw body, kiểm tra timestamp trong cửa sổ cho phép, tính HMAC-SHA256 trên timestamp và body bằng secret lấy từ biến môi trường, rồi so sánh bằng
MessageDigest.isEqualđể tránh timing attack. Sau đó mới parse JSON và xử lý idempotent theo event id.
🇬🇧 English:
I read the raw body, check the timestamp is within a tolerance window, compute HMAC-SHA256 over the timestamp and body with a secret from the environment, and compare using
MessageDigest.isEqualto avoid timing attacks. Only then do I parse the JSON and process it idempotently by event id.
Q3. Vì sao MD5 và SHA-1 không còn an toàn?
🇻🇳 Tiếng Việt:
Cả hai đã bị tìm ra collision thực tế, nên kẻ tấn công có thể tạo hai tài liệu khác nhau cùng hash, phá chữ ký và certificate. Em dùng SHA-256 trở lên; MD5 chỉ còn dùng làm checksum không bảo mật.
🇬🇧 English:
Practical collisions have been found for both, so an attacker can craft two different documents with the same hash and break signatures and certificates. I use SHA-256 or stronger; MD5 is only acceptable as a non-security checksum.
Cách nhớ ngắn gọn
- Hash = không key; HMAC = hash + key, hai lớp.
- Verify trên raw body, so sánh bằng
MessageDigest.isEqual. - Chữ ký không chống replay: thêm timestamp + idempotency.
- MD5, SHA-1: chỉ là checksum, không phải bảo mật.
Tự kiểm tra
Ba tính chất của hash mật mã là gì?
Preimage resistance, second-preimage resistance, collision resistance.
Vì sao không dùng String.equals để so chữ ký?
Nó dừng ở byte sai đầu tiên, lộ thông tin qua thời gian phản hồi (timing attack). Dùng MessageDigest.isEqual.
Vì sao phải verify trên raw body thay vì object đã parse?
Serialize lại có thể đổi khoảng trắng, thứ tự field, encoding số, làm tag khác dù dữ liệu cùng nghĩa.
Length extension attack áp dụng cho cấu trúc nào?
H(secret || message) với hash Merkle–Damgård như SHA-256. HMAC không bị.
HMAC có cung cấp non-repudiation không?
Không. Hai bên cùng giữ secret nên bên nào cũng tạo được tag. Cần chữ ký số.