Hướng dẫn tạo Amazon Pay Button bằng Golang (đủ 2 version V1 & V2)

Bài này là “wiki thực chiến”: làm nút Amazon Pay chạy được với Golang, hiểu cơ chế ký, lệnh tạo khóa, code mẫu cho V1V2, cách dùng AWS KMS (cho V2), tích hợp FE, và checklist debug khi gặp lỗi InvalidSignatureError.


1) Hiểu đúng “button signature”

Khi hiển thị nút, bạn phải cung cấp 3 thứ cho JS SDK của Amazon:

  • payloadJSON: chuỗi JSON mô tả checkout (URL quay lại, storeId, scopes…)
  • signature: chữ ký của chuỗi theo quy tắc Amazon
  • algorithm: phiên bản thuật toán ký

hai phiên bản:

Phiên bản algorithm trên FE Cách ký (RSA-PSS)
V1 AMZN-PAY-RSASSA-PSS SHA-256 + PSS saltLen=20
V2 AMZN-PAY-RSASSA-PSS-V2 SHA-256 + PSS saltLen=32

Điểm mấu chốt (theo SDK Amazon Pay, hàm generateButtonSignature):

signedString = "<ALGORITHM>\n" + hex( sha256( stripcslashes(payloadJSON) ) )
signature    = RSASSA-PSS-SHA256( signedString ) // saltLen tùy theo V1/V2

Quan trọng:signedString, không ký trực tiếp payloadJSON.


2) Tạo cặp khóa & PublicKeyId

Bạn cần private key PEM để ký, và public key để đăng ký với Amazon nhằm lấy PublicKeyId (ID bạn dùng ở FE).

Lệnh OpenSSL (RSA 2048)

# 1) Private key (PKCS#8, khuyến nghị)
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem

# 2) Public key (PEM)
openssl rsa -in private.pem -pubout -out public.pem

Upload public.pem lên Seller/Integration Central → nhận PublicKeyId (thường có prefix SANDBOX-... hoặc LIVE-...).
PublicKeyIdID, không phải nội dung key; không thể tự “tạo ID” bằng openssl.


3) Xây payloadJSON ổn định trong Go

Amazon so sánh theo byte. Vì vậy:

  • Dùng struct để có JSON ổn định.
  • Dùng json.Encoder + SetEscapeHTML(false) để không biến & thành \u0026.
  • Dùng json.Compact để loại newline/khoảng trắng dư thừa.
  • FE phải dùng đúng chuỗi payloadJSON backend trả về (không stringify lại).

4) Golang: thư viện nhỏ ký giống hệt generateButtonSignature (V1 & V2)

4.1. Mã nguồn (thư mục amazonpay/button_signature.go)

package amazonpay

import (
	"bytes"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"strings"
)

const (
	AlgV1 = "AMZN-PAY-RSASSA-PSS"     // saltLen = 20
	AlgV2 = "AMZN-PAY-RSASSA-PSS-V2" // saltLen = 32
)

// GenerateButtonSignature tái hiện đúng logic PHP generateButtonSignature.
// Trả về: signature(base64), payloadJSON(đã ký), signedString(để debug).
func GenerateButtonSignature(privateKeyPEM []byte, algorithm string, payload any) (sigB64, payloadJSON, signedString string, err error) {
	// 1) chọn algorithm
	saltLen := 0
	switch algorithm {
	case AlgV1:
		saltLen = 20
	case AlgV2:
		saltLen = 32
	default:
		return "", "", "", fmt.Errorf("invalid algorithm: %s", algorithm)
	}

	// 2) parse private key
	priv, err := parseRSAPrivateKey(privateKeyPEM)
	if err != nil {
		return "", "", "", fmt.Errorf("parse private key: %w", err)
	}

	// 3) payloadJSON: JSON không escape &, compact
	payloadJSON, err = toJSONNoEscape(payload)
	if err != nil {
		return "", "", "", fmt.Errorf("encode payload: %w", err)
	}

	// 4) PHP stripcslashes(payload) trước khi hash
	normalized := stripcslashesLikePHP(payloadJSON)

	// 5) hash payload -> hex lowercase, dựng signedString
	h := sha256.Sum256([]byte(normalized))
	hashHex := strings.ToLower(hex.EncodeToString(h[:]))
	signedString = algorithm + "\n" + hashHex

	// 6) sha256(signedString) + RSA-PSS SHA-256 (saltLen tùy phiên bản)
	d := sha256.Sum256([]byte(signedString))
	sig, err := rsa.SignPSS(rand.Reader, priv, crypto.SHA256, d[:],
		&rsa.PSSOptions{SaltLength: saltLen, Hash: crypto.SHA256})
	if err != nil {
		return "", "", "", fmt.Errorf("rsa.SignPSS: %w", err)
	}

	return base64.StdEncoding.EncodeToString(sig), payloadJSON, signedString, nil
}

// ----------------- Helpers -----------------

func parseRSAPrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
	block, _ := pem.Decode(pemBytes)
	if block == nil {
		return nil, errors.New("invalid PEM")
	}
	// PKCS#1
	if k1, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
		return k1, nil
	}
	// PKCS#8
	any, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err == nil {
		if k2, ok := any.(*rsa.PrivateKey); ok {
			return k2, nil
		}
		return nil, errors.New("pkcs8 is not RSA")
	}
	return nil, errors.New("unsupported private key format")
}

func toJSONNoEscape(v any) (string, error) {
	switch t := v.(type) {
	case string:
		return t, nil
	case []byte:
		return string(t), nil
	default:
		var buf bytes.Buffer
		enc := json.NewEncoder(&buf)
		enc.SetEscapeHTML(false) // giữ nguyên '&'
		if err := enc.Encode(v); err != nil {
			return "", err
		}
		var out bytes.Buffer           // compact bỏ newline/space dư
		if err := json.Compact(&out, buf.Bytes()); err != nil {
			return "", err
		}
		return out.String(), nil
	}
}

// Bản rút gọn tương đương stripcslashes PHP cho các escape JSON hay gặp
func stripcslashesLikePHP(s string) string {
	return strings.NewReplacer(`\/`, `/`, `\"`, `"`, `\\`, `\`).Replace(s)
}

4.2. Ví dụ dùng (V1 & V2)

// ví dụ payload typed để JSON ổn định
type WebCheckoutDetails struct {
	CheckoutReviewReturnURL string `json:"checkoutReviewReturnUrl"`
	CheckoutCancelURL       string `json:"checkoutCancelUrl"`
}
type PaymentDetails struct {
	AllowOvercharge bool `json:"allowOvercharge"`
}
type CheckoutPayload struct {
	WebCheckoutDetails WebCheckoutDetails `json:"webCheckoutDetails"`
	StoreID            string             `json:"storeId"`
	Scopes             []string           `json:"scopes,omitempty"`
	PaymentDetails     *PaymentDetails    `json:"paymentDetails,omitempty"`
}

// V1
pemBytes := mustRead("private.pem")
payload := CheckoutPayload{
	WebCheckoutDetails: WebCheckoutDetails{
		CheckoutReviewReturnURL: "https://example.com/review",
		CheckoutCancelURL:       "https://example.com/cancel",
	},
	StoreID: "amzn1.application-oa2-client.xxxxx",
	Scopes:  []string{"name","email","billingAddress","phoneNumber"},
	PaymentDetails: &PaymentDetails{AllowOvercharge: true},
}
sigV1, payloadJSON, signedV1, err := amazonpay.GenerateButtonSignature(pemBytes, amazonpay.AlgV1, payload)

// V2 (chỉ khác tham số algorithm)
sigV2, payloadJSON2, signedV2, err := amazonpay.GenerateButtonSignature(pemBytes, amazonpay.AlgV2, payload)

Tại FE, đặt algorithm đúng với chữ ký (AlgV1 hoặc AlgV2), publicKeyId ở cấp root, và gán nguyên xi payloadJSON từ backend.


5) Tích hợp FE (mẫu tối thiểu)

<script src="https://static-fe.payments-amazon.com/checkout.js"></script>
<script>
(async () => {
  const r = await fetch('/api/amazonpay/sign'); // trả về payloadJSON, signature, algorithm, publicKeyId
  const { payloadJSON, signature, algorithm, publicKeyId } = await r.json();

  amazon.Pay.renderButton('#AmazonPayButton', {
    merchantId: 'A2ZXXXXXXX',
    publicKeyId,                 // ví dụ: "SANDBOX-XXXX"
    ledgerCurrency: 'JPY',
    sandbox: true,
    productType: 'PayAndShip',
    placement: 'Cart',
    createCheckoutSessionConfig: {
      payloadJSON,               // dùng chính chuỗi từ backend
      signature,                 // chữ ký base64
      algorithm                  // "AMZN-PAY-RSASSA-PSS" hoặc "...-V2"
    }
  });
})();
</script>

Khi chạy file html trên trình duyệt, bạn sẽ thấy button hiện ra như ảnh:

Khi click vào button, nếu chữ ký đúng, thì sẽ hiện thông tin thanh toán:


6) Dùng AWS KMS (chỉ cho V2)

Nếu muốn ký bằng “thư viện AWS”, hãy dùng KMS với RSASSA_PSS_SHA_256 (tương đương V2). Quy trình:

  1. Tạo key SIGN_VERIFY (RSA_2048) trong KMS, hoặc import khóa vào KMS (nếu cần).
  2. Lấy public key từ KMS và đăng ký với Amazon để lấy PublicKeyId.
  3. Ở Go: vẫn build signedString như mục 4, nhưng chữ ký lấy từ kms.Sign.

Code KMS (đưa signedString vào, KMS hash hộ – MessageType RAW)

import (
  "context"
  "encoding/base64"

  "github.com/aws/aws-sdk-go-v2/config"
  "github.com/aws/aws-sdk-go-v2/service/kms"
  "github.com/aws/aws-sdk-go-v2/service/kms/types"
)

func SignWithKMSV2(ctx context.Context, keyID, signedString string) (string, error) {
  cfg, err := config.LoadDefaultConfig(ctx)
  if err != nil { return "", err }
  cli := kms.NewFromConfig(cfg)

  out, err := cli.Sign(ctx, &kms.SignInput{
    KeyId:            &keyID,
    Message:          []byte(signedString),             // đưa signedString THÔ
    MessageType:      types.MessageTypeRaw,             // để KMS tự SHA-256
    SigningAlgorithm: types.SigningAlgorithmSpecRsassaPssSha256,
  })
  if err != nil { return "", err }
  return base64.StdEncoding.EncodeToString(out.Signature), nil
}

Với KMS, chỉ dùng V2 (AlgV2). V1 yêu cầu saltLen=20, KMS không cho đặt saltLen tùy ý.


7) Verify cục bộ (nên làm 1 lần)

Khi có public key PEM, có thể xác nhận chữ ký ngay tại backend:

func VerifyButtonSignature(pubPEM []byte, algorithm, payloadJSON, sigB64 string) error {
  // parse public
  block, _ := pem.Decode(pubPEM)
  if block == nil { return errors.New("invalid public PEM") }
  any, err := x509.ParsePKIXPublicKey(block.Bytes)
  if err != nil { return err }
  pub, ok := any.(*rsa.PublicKey)
  if !ok { return errors.New("not RSA public key") }

  // stripcslashes + hash
  normalized := stripcslashesLikePHP(payloadJSON)
  h := sha256.Sum256([]byte(normalized))
  hashHex := strings.ToLower(hex.EncodeToString(h[:]))
  signedString := algorithm + "\n" + hashHex

  // verify PSS
  sum := sha256.Sum256([]byte(signedString))
  saltLen := 20
  if algorithm == AlgV2 { saltLen = 32 }

  sig, _ := base64.StdEncoding.DecodeString(sigB64)
  return rsa.VerifyPSS(pub, crypto.SHA256, sum[:], sig,
    &rsa.PSSOptions{SaltLength: saltLen, Hash: crypto.SHA256})
}

8) Checklist debug InvalidSignatureError

  1. So khớp “signedString …”
    Amazon thường hiện: … signedString AMZN-PAY-… <HEX>.
    Hãy log signedString ở backend (mục 4 đã trả về). Phần <HEX> phải trùng.

  2. Chuỗi payloadJSON
    FE phải dùng đúng chuỗi backend trả về. Tuyệt đối không stringify lại hoặc tự gõ tay khác 1 ký tự.

  3. Algorithm
    Ký V1 thì FE set AMZN-PAY-RSASSA-PSS. Ký V2 thì FE set AMZN-PAY-RSASSA-PSS-V2.

  4. publicKeyId
    Đặt ở cấp root (không đặt trong createCheckoutSessionConfig), đúng môi trường (SANDBOX- với sandbox), đúng merchant, và khớp cặp private key.

  5. URL phải HTTPS và đúng domain đã khai báo (không gây lỗi chữ ký, nhưng sẽ vấp ở bước sau nếu sai).


9) Câu hỏi thường gặp

  • Vì sao chữ ký trước đây luôn sai?
    Thường do ký sai đối tượng (ký trực tiếp payloadJSON), thiếu bước signedString, hoặc JSON bị escape & thành \u0026.

  • Có thể dùng aws/signer/v4 không?
    Không. Đó là HMAC cho dịch vụ AWS, không phải Amazon Pay Button.

  • KMS có dùng cho V1 không?
    Không. KMS PSS SHA-256 tương đương V2 (saltLen=32). Nếu cần V1, ký local bằng private key PEM.


10) Kết luận

  • Hãy coi hàm GenerateButtonSignature ở trên là “chuẩn vàng” cho Go: bám sát SDK PHP/Node, hỗ trợ V1 và V2.
  • Nếu muốn “thuần AWS”, dùng KMS cho V2.
  • Khi lỗi, so ngay signedString và (nếu có) verify cục bộ để tìm đúng nguyên nhân.

Chúc bạn triển khai trơn tru!