Hash và HMAC: verify webhook thanh toán

4 câu hỏi~25 phútTừ khóa: SHA-256, collision, MD5, SHA-1, HMAC, webhook signature, constant-time compare, replay, length extension

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

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 resistanceCó hash, không tìm được input
Second-preimage resistanceCó input A, không tìm được B khác A cùng hash
Collision resistanceKhô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 keyKhôngCóCó (shared)Private key ký, public key verify
Chống giả mạoKhôngLỗi length extensionCóCó
Non-repudiationKhôngKhôngKhông (hai bên cùng key)Có
Tốc độRất nhanhRất nhanhRất nhanhChậm hơn

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

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:

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.isEqual to 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


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ố.