Go 签名与验签示例
运行环境
Go 1.18+,仅使用标准库。将两个文件放入同一 paymentsign 包。
统一规则
这些示例用于默认商户 API 和默认平台回调,不适用于另行约定的定制签名格式。中英文页面引用同一份可执行代码。
有请求体的 API 请求,使用商户私钥签名:
path + lowercaseHex(SHA512(rawBody)) + Timestamp
无请求体的 API 请求,使用商户私钥签名:
path + Timestamp
默认异步回调,使用平台公钥验签:
lowercaseHex(SHA512(rawBody)) + Timestamp
API 同步响应封装,使用平台公钥验签:
lowercaseHex(SHA512(平台序列化的 data 字节)) + timestamp
- 算法统一为 RSA PKCS#1 v1.5 + SHA-512,签名使用标准 Base64,不是 RSA-PSS,也不是对原文做 RSA 加密。
Timestamp使用秒级 Unix 时间戳;验签使用收到的时间戳,不要重新生成。Header 名称不区分大小写,但 Base64 签名值区分大小写。- 请求
path是接口路径,不包含协议、域名或查询字符串。回调原文不拼路径、URL 或分隔符。 - JSON 只序列化一次:签名和发送必须使用同一份 UTF-8 字节。字段顺序不要求预先排序,但空格、转义、换行、数字表示和顺序必须与发送内容完全一致。
- 无请求体使用
null/nil/None;它与“存在但长度为零的请求体”不同。回调始终对收到的完整 body 求摘要。 - 私钥支持未加密 PKCS#1、PKCS#8;公钥支持 X.509 SPKI、PKCS#1;支持 PEM、纯 Base64 和转义换行。证书、加密私钥、SSH 格式及非 RSA 密钥不在本示例支持范围内。
- 商户请求用商户私钥;验签必须使用后台配置的 平台公钥。不能使用商户自己的公钥,也不能信任请求中携带的公钥。不要发送私钥给技术支持;泄露后应更换。
失败处理
签名失败抛出异常或返回 error,调用方必须停止发送,不能用空签名继续请求。验签成功正常返回,失败记录 [payment-verify] FAILED 并抛出异常或返回 error;不得忽略失败后继续入账。
日志包含阶段、字节长度和错误原因,不记录原始请求体、完整签名或密钥。生产环境不要打开 HTTP 正文日志;不要把内部诊断错误原样返回给调用方,并对失败请求和日志做限流。
HTTPS 必须校验证书与主机名。网络超时不能证明订单创建失败,不能盲目自动重新下单;应按商户订单号查询或人工核查。
回调接入
先限制请求体大小,读取原始字节,读取 Timestamp、Signature,验签成功后再解析 JSON。检查订单归属、金额、币种和允许的状态变化,通过事务做持久化幂等处理。只有完成可靠处理后才按协议返回 SUCCESS。重复通知不得重复产生资金变动。
验签函数只负责密码学校验,不等于防重放。时间窗口应结合平台重试规则设置;重放检测、持久化幂等、回调请求限流仍由接入方实现。不要因固定短时间窗口而拒绝正常重试。
同步响应不能当作回调
API 同步响应从 JSON 的 timestamp、sign 字段取值,验签对象是 data 的平台序列化字节,不是整个响应体;回调则从 Header 取值,验签对象是完整 HTTP body。
不要把 data 解析后重新序列化再验签:大整数、1.00、转义或字段顺序可能改变。使用能保留 JSON 原始值文本的解析器提取 data(例如 Go json.RawMessage、C# JsonElement.GetRawText()),并把这些 UTF-8 字节传给示例的 verifyPlatform / VerifyPlatform / verify_platform。data 是字符串时保留 JSON 引号与转义,是 null 时保留字面量 null;不要自行添加换行或空格。缺少签名、字段异常或验签失败必须拒绝作为可信业务结果。
接口路径
同一请求函数可用于以下路径,参数以相应币种文档为准:
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /v1/merchant/pay | 代收下单 |
| POST | /v1/merchant/pay/order/info | 代收查单 |
| POST | /v1/merchant/payout | 代付下单 |
| POST | /v1/merchant/payout/order/info | 代付查单 |
| GET | /v1/merchant/account | 余额查询,无请求体 |
签名与验签完整代码
sign.go
package paymentsign
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha512"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"log"
"strconv"
"strings"
)
func keyDER(input string, private bool) ([]byte, error) {
if len(input) > 16384 {
return nil, fmt.Errorf("key: exceeds 16 KiB")
}
input = strings.TrimSpace(input)
input = strings.ReplaceAll(input, `\r\n`, "\n")
input = strings.ReplaceAll(input, `\n`, "\n")
if strings.HasPrefix(input, "-----BEGIN") {
block, rest := pem.Decode([]byte(input))
if block == nil || strings.TrimSpace(string(rest)) != "" {
return nil, fmt.Errorf("key: expected one complete PEM block")
}
allowed := block.Type == "PUBLIC KEY" || block.Type == "RSA PUBLIC KEY"
if private {
allowed = block.Type == "PRIVATE KEY" || block.Type == "RSA PRIVATE KEY"
}
if !allowed || len(block.Headers) != 0 {
return nil, fmt.Errorf("key: expected unencrypted RSA PEM of the correct kind")
}
return block.Bytes, nil
}
return decodeBase64(strings.Join(strings.Fields(input), ""), "key")
}
func decodeBase64(text, label string) ([]byte, error) {
value, err := base64.StdEncoding.DecodeString(text)
if err != nil || len(value) == 0 || base64.StdEncoding.EncodeToString(value) != text {
return nil, fmt.Errorf("%s: invalid Base64", label)
}
return value, nil
}
func privateKey(input string) (*rsa.PrivateKey, error) {
der, err := keyDER(input, true)
if err != nil {
return nil, err
}
if key, err := x509.ParsePKCS1PrivateKey(der); err == nil {
return key, nil
}
key, err := x509.ParsePKCS8PrivateKey(der)
if err == nil {
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
return rsaKey, nil
}
}
return nil, fmt.Errorf("key: invalid/truncated, encrypted or non-RSA private key; DER bytes=%d", len(der))
}
func publicKey(input string) (*rsa.PublicKey, error) {
der, err := keyDER(input, false)
if err != nil {
return nil, err
}
if key, err := x509.ParsePKCS1PublicKey(der); err == nil {
return key, nil
}
key, err := x509.ParsePKIXPublicKey(der)
if err == nil {
if rsaKey, ok := key.(*rsa.PublicKey); ok {
return rsaKey, nil
}
}
return nil, fmt.Errorf("key: invalid/truncated or non-RSA public key; DER bytes=%d", len(der))
}
func validTimestamp(text string) error {
value, err := strconv.ParseInt(text, 10, 64)
if err != nil || value <= 0 || strconv.FormatInt(value, 10) != text {
return fmt.Errorf("invalid Timestamp: use Unix seconds")
}
return nil
}
func bodyHash(body []byte) string {
digest := sha512.Sum512(body)
return hex.EncodeToString(digest[:])
}
// nil means no request body; []byte{} means a present, empty body.
func SignRequest(path string, body []byte, timestamp, merchantPrivateKey string) (signature string, err error) {
defer func() {
if err != nil {
log.Printf("[payment-sign] FAILED body_bytes=%d error=%q", len(body), err.Error())
}
}()
if !strings.HasPrefix(path, "/") {
return "", fmt.Errorf("path: expected API path")
}
if err := validTimestamp(timestamp); err != nil {
return "", err
}
key, err := privateKey(merchantPrivateKey)
if err != nil {
return "", err
}
hash := ""
if body != nil {
hash = bodyHash(body)
}
digest := sha512.Sum512([]byte(path + hash + timestamp))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA512, digest[:])
if err != nil {
return "", fmt.Errorf("RSA SHA512 signing failed: %w", err)
}
return base64.StdEncoding.EncodeToString(sig), nil
}
// Exact callback bytes, or exact signed data bytes for API envelopes. NO path.
func VerifyPlatform(body []byte, timestamp, signature, platformPublicKey string) (err error) {
defer func() {
if err != nil {
log.Printf("[payment-verify] FAILED body_bytes=%d error=%q", len(body), err.Error())
}
}()
if err := validTimestamp(timestamp); err != nil {
return err
}
key, err := publicKey(platformPublicKey)
if err != nil {
return err
}
sig, err := decodeBase64(signature, "Signature")
if err != nil {
return err
}
if len(sig) != key.Size() {
return fmt.Errorf("Signature length: got %d, expected %d bytes", len(sig), key.Size())
}
digest := sha512.Sum512([]byte(bodyHash(body) + timestamp))
if err := rsa.VerifyPKCS1v15(key, crypto.SHA512, digest[:], sig); err != nil {
return fmt.Errorf("RSA SHA512 verification failed: check platform key, raw bytes, timestamp and formula: %w", err)
}
return nil
}
HTTP 请求工具
http.go
package paymentsign
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// rawBody is the exact serialized bytes, or nil for a bodyless GET.
func Send(ctx context.Context, baseURL, path, merchantCode string, rawBody []byte, merchantPrivateKey string) ([]byte, error) {
if !strings.HasPrefix(baseURL, "https://") {
return nil, fmt.Errorf("HTTPS is required")
}
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
signature, err := SignRequest(path, rawBody, timestamp, merchantPrivateKey)
if err != nil {
return nil, err
}
method := http.MethodPost
var body io.Reader = bytes.NewReader(rawBody)
if rawBody == nil {
method = http.MethodGet
body = nil
}
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Merchant-Code", merchantCode)
req.Header.Set("Timestamp", timestamp)
req.Header.Set("Signature", signature)
client := &http.Client{Timeout: 60 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
response, err := client.Do(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP %d; reconcile before retrying", response.StatusCode)
}
result, err := io.ReadAll(io.LimitReader(response.Body, (8<<20)+1))
if err != nil {
return nil, err
}
if len(result) > 8<<20 {
return nil, fmt.Errorf("response exceeds 8 MiB")
}
return result, nil // Preserve raw response for envelope verification.
}
接入示例
以下片段放在接入方应用中使用,payload、可信配置和事务业务处理函数由接入方提供;示例不会自动发起真实下单。
// Imports: encoding/json, strconv, time, io, net/http.
body, err := json.Marshal(payload)
if err != nil { return err }
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
signature, err := paymentsign.SignRequest("/v1/merchant/pay", body, timestamp, merchantPrivateKey)
if err != nil { return err }
// Transmit exactly body; use timestamp/signature as headers. Do not marshal again.
// No body: paymentsign.SignRequest("/v1/merchant/account", nil, timestamp, merchantPrivateKey)
// Inside an http.Handler; configuredPlatformPublicKey is loaded from trusted configuration.
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
rawBody, err := io.ReadAll(r.Body)
if err != nil { http.Error(w, "invalid body", 400); return }
if err := paymentsign.VerifyPlatform(rawBody, r.Header.Get("Timestamp"),
r.Header.Get("Signature"), configuredPlatformPublicKey); err != nil {
http.Error(w, "invalid signature", 401); return
}
// Only now decode JSON, validate the order and commit an idempotent update.
// Write SUCCESS only after durable processing.
