商户侧 /api/client/v1、源头侧 /api/open/v1 与乐玩开放平台各有一套签名实现, 其中语义完全一致的部分重复了多份,任一处修复都容易漏改另外两处。 - 新增 internal/pkg/signutil,只收敛四类一致性原语:HMAC-SHA256→hex、 body SHA256 摘要、时间戳偏差校验、常数时间比较 - 中间件与 lewan 改为调用该包,删除 middleware 里重复的 hmacSHA256Hex 与 abs64 - 刻意不统一「签名串构造」:三者拼接方式是对外契约且互不相同 (商户:字典序 & + body 摘要;源头:字典序 & + body 原文;乐玩:换行 6 行), 强行合并会直接改变握手结果 - 保留各处原有细节语义:商户侧十六进制签名大小写不敏感、乐玩签名原样比较、 VerifyWebhook 在 allowedSkew<=0 时仍要求时间戳可解析 验证:三套方案既有端到端测试全部通过(含乐玩官方测试向量、大写签名、重放拒绝); 新增 signutil 已知向量与偏差/比较边界测试,以及 VerifyWebhook 边界语义测试。
83 lines
3.2 KiB
Go
83 lines
3.2 KiB
Go
// Package signutil 提供开放接口签名共用的底层原语。
|
||
//
|
||
// 这里只收敛三套签名实现中语义完全一致的部分:HMAC-SHA256→hex、body 摘要、
|
||
// 时间戳偏差校验、常数时间比较。
|
||
//
|
||
// 各接口的「签名串构造方式」刻意不在这里统一——它们是对外契约,彼此不同:
|
||
// - 商户侧 /api/client/v1:字典序 & 拼接,body 以 SHA256 摘要参与
|
||
// - 源头侧 /api/open/v1 :字典序 & 拼接,body 原文参与
|
||
// - 乐玩开放平台 :换行分隔的 6 行签名串(method/path/query/bodyHash/ts/nonce)
|
||
//
|
||
// 把它们合并成一套「通用」实现会直接改变握手结果,属于破坏性变更。
|
||
package signutil
|
||
|
||
import (
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"crypto/subtle"
|
||
"encoding/hex"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// EmptyBodySHA256 是空字节串的十六进制 SHA-256 摘要。
|
||
const EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||
|
||
// HMACSHA256Hex 用 secret 对 content 计算 HMAC-SHA256,返回小写十六进制。
|
||
func HMACSHA256Hex(secret, content string) string {
|
||
return HMACSHA256HexBytes(secret, []byte(content))
|
||
}
|
||
|
||
// HMACSHA256HexBytes 同上,但内容由多段字节依次写入。
|
||
// webhook 签名需要「前缀 + 原始 body」这种分段写入,用它可以避免先拼接再拷贝。
|
||
func HMACSHA256HexBytes(secret string, parts ...[]byte) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
for _, part := range parts {
|
||
_, _ = mac.Write(part)
|
||
}
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// SHA256Hex 返回字节的十六进制 SHA-256 摘要;空内容返回空串摘要常量。
|
||
func SHA256Hex(body []byte) string {
|
||
if len(body) == 0 {
|
||
return EmptyBodySHA256
|
||
}
|
||
sum := sha256.Sum256(body)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// TimestampWithinSkew 判断 Unix 秒时间戳与当前时间的偏差是否不超过 skew。
|
||
//
|
||
// 时间戳必须是纯十进制(不裁剪空白),与各调用点原有行为保持一致。
|
||
// skew <= 0 时只有时间戳与当前秒完全相同才通过——需要「0 表示不校验」语义的调用方
|
||
// 请自行判断后再调用,以免语义被悄悄改变。
|
||
func TimestampWithinSkew(timestamp string, skew time.Duration) bool {
|
||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
diff := time.Now().Unix() - ts
|
||
if diff < 0 {
|
||
diff = -diff
|
||
}
|
||
return time.Duration(diff)*time.Second <= skew
|
||
}
|
||
|
||
// TimestampWithinSkewSeconds 是 TimestampWithinSkew 的秒参数版本。
|
||
func TimestampWithinSkewSeconds(timestamp string, skewSeconds int64) bool {
|
||
return TimestampWithinSkew(timestamp, time.Duration(skewSeconds)*time.Second)
|
||
}
|
||
|
||
// EqualSignatures 以常数时间比较两个签名字符串,不做任何大小写归一化。
|
||
func EqualSignatures(actual, expected string) bool {
|
||
return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
|
||
}
|
||
|
||
// EqualHexSignatures 以常数时间比较两个十六进制签名,比较前统一转小写
|
||
// (十六进制本身大小写不敏感,调用方允许大写签名)。
|
||
func EqualHexSignatures(actual, expected string) bool {
|
||
return EqualSignatures(strings.ToLower(actual), strings.ToLower(expected))
|
||
}
|