Click Pay 文档Click Pay 文档
  • en-US
  • 简体中文
  • en-US
  • 简体中文
  • Go 签名与验签示例

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.
最近更新: 2026/9/22 14:41
Contributors: aa, aaa