Skip to content

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:

json
{
  "payload": "<base64url-encoded notification JSON>",
  "signatures": [
    {
      "protected": "<base64url-encoded JWS header>",
      "signature": "<base64url-encoded signature>"
    }
  ]
}
TrườngMô tả
payloadJSON thông báo được mã hóa base64url. Giải mã trường này để đọc event.
signatures[0].protectedProtected header JWS được mã hóa base64url. Chứa alg, iat, kid, typ, và url.
signatures[0].signatureChữ ký được mã hóa base64url trên protected header và payload.

Protected header đã giải mã ​

json
{
  "alg": "ES256",
  "iat": 1773308486,
  "kid": "SkP41XUw7hiTkAf6Zmbe4o",
  "typ": "JWS",
  "url": "http://localhost:8000"
}
TrườngMô tả
algThuật toán ký. Luôn là ES256 (ECDSA với P-256 và SHA-256).
iatUnix 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.
kidKey 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.
typLoại token. Luôn là JWS.
urlURL 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:

  1. JSON thông báo được serialize và mã hóa base64url thành payload của JWS.
  2. Một protected header được xây dựng chứa alg, iat, kid, typ, và url mục tiêu.
  3. 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).
  4. 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:

json
{
  "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.

json
{
  "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.

bash
curl --location 'https://bn.cakedigibank.tech/api/public-keys'
json
{
  "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-kavcgzGLrtNSw

5. Xác thực claim ​

Sau khi chữ ký hợp lệ, kiểm tra:

  • url trong 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ụ ​

go
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))
}

Developers