Chữ ký webhook
Mọi request webhook từ Bahn được gửi dưới dạng một JWS JSON Serialization (Content-Type: application/jose+json). Chữ ký cho phép bạn xác minh rằng request thực sự được gửi bởi Bahn và không bị can thiệp.
Cấu trúc request body
Request body là một đối tượng JWS với cấu trúc sau:
{
"payload": "<base64url-encoded notification JSON>",
"signatures": [
{
"protected": "<base64url-encoded JWS header>",
"signature": "<base64url-encoded signature>"
}
]
}| Trường | Mô tả |
|---|---|
payload | JSON thông báo được mã hóa base64url. Giải mã trường này để đọc event. |
signatures[0].protected | Protected header JWS được mã hóa base64url. Chứa alg, iat, kid, typ, và url. |
signatures[0].signature | Chữ ký được mã hóa base64url trên protected header và payload. |
Protected header đã giải mã
{
"alg": "ES256",
"iat": 1773308486,
"kid": "SkP41XUw7hiTkAf6Zmbe4o",
"typ": "JWS",
"url": "http://localhost:8000"
}| Trường | Mô tả |
|---|---|
alg | Thuật toán ký. Luôn là ES256 (ECDSA với P-256 và SHA-256). |
iat | Unix timestamp (giây) của thời điểm event được phát hành. Dùng giá trị này để từ chối các request bị replay. |
kid | Key ID của signing key của Bahn được sử dụng. Dùng giá trị này để tra cứu public key chính xác. |
typ | Loại token. Luôn là JWS. |
url | URL webhook đã đăng ký mà event này được gửi đến. |
Cách chữ ký được tạo
Khi Bahn gửi một webhook:
- JSON thông báo được serialize và mã hóa base64url thành
payloadcủa JWS. - Một protected header được xây dựng chứa
alg,iat,kid,typ, vàurlmục tiêu. - Header và payload được ký bằng ES256 (ECDSA P-256) và private key của Bahn, tạo ra một JWS compact (
header.payload.signature). - JWS compact được cấu trúc lại thành định dạng JWS JSON Serialization và gửi làm request body.
Cách xác minh
1. Phân tích body JWS
Đọc request body và phân tích JWS JSON Serialization:
{
"payload": "eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9",
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0",
"signature": "HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw"
}
]
}2. Giải mã protected header
Giải mã base64url signatures[0].protected và phân tích JSON. Trích xuất kid, iat, và url.
{
"alg": "ES256",
"iat": 1773660576,
"kid": "SkP41XUw7hiTkAf6Zmbe4o",
"typ": "JWS",
"url": "https://bn.cakedigibank.tech/merchant-simulation/webhook"
}3. Lấy public key của Bahn
Gọi endpoint [GetPublicKeys] của Bahn và tìm key có kid khớp với giá trị từ header.
curl --location 'https://bn.cakedigibank.tech/api/public-keys'{
"keys": [
{
"kty": "EC",
"alg": "",
"use": "sig",
"kid": "SkP41XUw7hiTkAf6Zmbe4o",
"n": "",
"e": "",
"x": "7HY5YbrgGN87Y-Nuifir8O1Nmkb0tflqo17jN5bvu5A",
"y": "B_WVN6Ar4BHTWwvsfoUy3_KcHverFu69Ajby6bvEcEY",
"crv": "P-256"
}
]
}4. Xác minh chữ ký
Tái tạo lại JWS compact và xác minh nó bằng public key đã khớp và ES256:
<signatures[0].protected>.<payload>.<signatures[0].signature>eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0
.
eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9
.
HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw5. Xác thực claim
Sau khi chữ ký hợp lệ, kiểm tra:
urltrong protected header khớp với URL webhook endpoint đã đăng ký của bạn.iatđủ gần đây để từ chối các request bị replay (ví dụ từ chối nếu cũ hơn 5 phút).
WARNING
Từ chối bất kỳ request nào không vượt qua việc xác minh chữ ký, chứa một url không mong đợi, hoặc có iat quá cũ. Không xử lý payload của event cho đến khi mọi kiểm tra đều thành công.
Ví dụ
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/go-jose/go-jose/v4"
)
const publicKeyURL = "https://bn.cakedigibank.tech/api/public-keys"
var bahnKeys jose.JSONWebKeySet
func init() {
resp, err := http.Get(publicKeyURL)
if err != nil {
log.Fatalf("get public keys: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatalf("read body: %v", err)
}
err = json.Unmarshal(body, &bahnKeys)
if err != nil {
log.Fatalf("unmarshal public keys: %v", err)
}
}
func main() {
payload := []byte(`{
"payload": "eyJtZXJjaGFudF9pZCI6ImNfdGlja2V0IiwiZXZlbnRfaWQiOiI3NDI1NDhlNy00YzUzLTQwMjAtYmMyOC01NjU2N2ExYWY2ZDUiLCJldmVudF9uYW1lIjoicGF5bWVudF9pbnRlbnQuc3RhdHVzLnN1Y2NlZWRlZCIsImRhdGFfdHlwZSI6InBheW1lbnRfaW50ZW50IiwiZGF0YSI6eyJpZCI6ImM2YWY4ODU1LWNiZjYtNDJhMC05YzQ0LTM2ZGUwZGUwYTc5MyIsInN0YXR1cyI6InN1Y2NlZWRlZCIsImFtb3VudCI6IjE0MDAwIiwiY3VycmVuY3kiOiI3MDQiLCJwYXltZW50X21ldGhvZF9pZCI6IjAxOWNmNjY4LThiODktN2UyMi1hNmUzLWI4ODllY2M1NGI4NyIsImxhdGVzdF9jaGFyZ2VfaWQiOiI0ZjViMWFkNC0xYmI3LTQ0NTItOTMwMS04YWExMTI0MTc0YjAiLCJtZXRhZGF0YSI6eyJrZXlfMCI6InN0cmluZyIsImtleV8xIjoic3RyaW5nIn0sImNyZWF0ZWRfYXQiOiIyMDI2LTAzLTE2VDExOjI5OjI4Ljc5MzUxODYwMloiLCJ1cGRhdGVkX2F0IjoiMjAyNi0wMy0xNlQxMToyOToyOC44MjAwNjNaIiwiY2FuY2VsZWRfYXQiOm51bGx9LCJwdWJsaXNoZWRfYXQiOiIyMDI2LTAzLTE2VDE4OjI5OjM2LjI4MzIxNzA2NyswNzowMCJ9",
"signatures": [
{
"protected": "eyJhbGciOiJFUzI1NiIsImlhdCI6MTc3MzY2MDU3Niwia2lkIjoiU2tQNDFYVXc3aGlUa0FmNlptYmU0byIsInR5cCI6IkpXUyIsInVybCI6Imh0dHBzOi8vYm4uY2FrZWRpZ2liYW5rLnRlY2gvbWVyY2hhbnQtc2ltdWxhdGlvbi93ZWJob29rIn0",
"signature": "HT-RvOtf0Y0cUoCCEBRMkBAa1Q00d2ZxWOsOo0E0mH47MCvOsBGe87aZr4fV-EhEsnOtZWRg-kavcgzGLrtNSw"
}
]
}`)
jws, err := jose.ParseSigned(string(payload), []jose.SignatureAlgorithm{jose.ES256})
if err != nil {
log.Fatalf("parse signed: %v", err)
}
kid := jws.Signatures[0].Protected.KeyID
verifyKey := bahnKeys.Key(kid)
if len(verifyKey) == 0 {
log.Fatalf("key %s not found", kid)
}
verifiedPayload, err := jws.Verify(verifyKey[0].Key)
if err != nil {
log.Fatalf("verify: %v", err)
}
fmt.Println(string(verifiedPayload))
}