为什么是 RSA,不是 HMAC
商户的服务器要调我们的下单、查询、退款接口,安全是第一道门槛。没法靠 Session,也不能让 AppSecret 在网络上裸奔。我参考了微信支付、支付宝那一套签名机制,最后定下 RSA 非对称签名加 SHA256 摘要,再用时间戳和随机串防重放。
为什么不全用 HMAC?因为 HMAC 是对称的,平台手里也捏着商户的密钥,一旦平台侧泄露,商户没法自证清白。换成 RSA 就没有这个问题:商户的私钥自己保管,平台只存公钥,谁发的、有没有被改,验签时责任边界清清楚楚。
签名串怎么拼
签名串的构造规则我们定得很死:
- 按 ASCII 升序排列所有非空业务参数(不含
sign、sign_type、sign_version); - 拼接成
key1=value1&key2=value2; - 末尾追加上行请求体的 SHA256 摘要(JSON 原文,不做字段排序);
- 商户私钥做 SHA256WithRSA 签名,Base64 编码后放在
X-Sign Header; - 同时带
X-App-Id、X-Timestamp(秒)、X-Nonce。
定这么死是故意的。签名串最怕商户各自发挥:参数排序差一点、大小写差一点,验签就永远过不去,还查不出是哪边的问题。
构造逻辑我直接写成一个纯函数,平台和 SDK 共用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| func BuildSignString(query url.Values, body []byte) string {
keys := make([]string, 0, len(query))
for k := range query {
if k == "sign" || k == "sign_type" {
continue
}
if v := query.Get(k); v != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
var buf strings.Builder
for i, k := range keys {
if i > 0 {
buf.WriteByte('&')
}
buf.WriteString(k)
buf.WriteByte('=')
buf.WriteString(query.Get(k))
}
sum := sha256.Sum256(body)
buf.WriteString("&body_sha256=")
buf.WriteString(hex.EncodeToString(sum[:]))
return buf.String()
}
|
验签四道关
平台校验四件事:AppId 是否存在、时间戳是否在 5 分钟窗口内、Nonce 是否在 Redis 里没出现过(防重放)、签名是否通过。

验签挂在 Gin 的 OpenAPI 路由组上,是个中间件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| func RSAVerifyMiddleware(appDao dao.AppDAO, rdb *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
appID := c.GetHeader("X-App-Id")
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sign := c.GetHeader("X-Sign")
if appID == "" || timestamp == "" || nonce == "" || sign == "" {
c.AbortWithStatusJSON(401, gin.H{"code": "MISSING_AUTH_HEADERS"})
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
c.AbortWithStatusJSON(401, gin.H{"code": "TIMESTAMP_EXPIRED"})
return
}
nonceKey := "openapi:nonce:" + appID + ":" + nonce
if ok, _ := rdb.SetNX(c, nonceKey, 1, 10*time.Minute).Result(); !ok {
c.AbortWithStatusJSON(401, gin.H{"code": "REPLAY_DETECTED"})
return
}
app, err := appDao.FindByAppID(c, appID)
if err != nil || app.Status != "ACTIVE" {
c.AbortWithStatusJSON(401, gin.H{"code": "APP_NOT_FOUND"})
return
}
body, _ := c.GetRawData()
c.Request.Body = io.NopCloser(bytes.NewBuffer(body)) // 回放给后续 handler
signStr := BuildSignString(c.Request.URL.Query(), body)
pub, err := parseRSAPublicKey(app.PublicKey)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"code": "INVALID_PUBKEY"})
return
}
hashed := sha256.Sum256([]byte(signStr))
sig, _ := base64.StdEncoding.DecodeString(sign)
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, hashed[:], sig); err != nil {
c.AbortWithStatusJSON(401, gin.H{"code": "SIGN_INVALID"})
return
}
c.Set("app", app)
c.Next()
}
}
|
GetRawData 之后记得用 io.NopCloser 把 body 放回去,否则后续 ShouldBindJSON 会读到空。这是我第一次联调踩的坑。
四个权衡
第一是 GET 请求的 body 处理。GET 没有 body,我们约定 body_sha256 字段直接填空字符串的 SHA256(即 e3b0c442...),而不是省略这个字段。签名串结构稳定,SDK 不用写两套分支。
第二是 Nonce 的存储成本。Redis 存 10 分钟窗口的 Nonce,看着有压力,实际上按 AppId 隔离之后单商户 QPS 并不高,10 分钟过期自动回收,没必要上滑动窗口或布隆过滤器。
第三是密钥轮换。App 表里留了 public_key 和 public_key_prev 两个字段,轮换时新公钥进主字段、旧公钥降到 prev,中间件两把都试。商户有 24 小时灰度窗口,不会一刀切验签失败。
第四是要不要加密 body。平台已经全链路 HTTPS,签名本身防篡改,最后没做请求体加密,只有银行卡号这类敏感字段由业务层单独做字段级加密。给所有商户平添一层对接成本,不值。
上线之后
上线至今,没出过签名层面的安全事件。回头看,这套机制立得住,靠的是规则够死、边界够清:谁发的、有没有被改、是不是重放,一趟验签全解决。商户接入文档里我配了 Java、Python、Go 三份 SDK 示例,写文档花的功夫,比后来省下的联调时间少多了。
封面图:Simon A. Eugster / Wikimedia Commons · CC BY-SA 3.0