refactor(sign): 抽取公共签名原语 signutil,三套签名串格式保持不变

商户侧 /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 边界语义测试。
This commit is contained in:
yml2213
2026-10-06 19:53:55 +08:00
parent 5ed36c724f
commit a47b61a98d
6 changed files with 320 additions and 58 deletions
+8 -26
View File
@@ -2,17 +2,14 @@ package middleware
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"strconv"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/pkg/signutil"
"affiliate_dash/internal/service"
"github.com/gin-gonic/gin"
@@ -71,8 +68,8 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
// 时间戳非法或超出偏差窗口都视为过期;解析与偏差判断统一由 signutil 负责。
if !signutil.TimestampWithinSkew(timestamp, time.Duration(cfg.SkewSeconds)*time.Second) {
openlog.Warn(c, "open_auth expired app_key=%s ts=%s skew=%d", openlog.MaskKey(appKey), timestamp, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
@@ -113,10 +110,9 @@ func OpenAuth(cfg OpenAuthConfig) gin.HandlerFunc {
method := strings.ToUpper(c.Request.Method)
path := c.Request.URL.Path
expected := BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path, bodyBytes)
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
bodyHash := sha256.Sum256(bodyBytes)
if !signutil.EqualHexSignatures(sign, expected) {
openlog.Warn(c, "open_auth sign_mismatch app_key=%s method=%s path=%s body_sha256=%s sign=%s expected=%s",
openlog.MaskKey(appKey), method, path, hex.EncodeToString(bodyHash[:]),
openlog.MaskKey(appKey), method, path, signutil.SHA256Hex(bodyBytes),
openlog.MaskSign(sign), openlog.MaskSign(expected))
response.Unauthorized(c, "签名校验失败")
c.Abort()
@@ -189,16 +185,15 @@ func GetMerchantID(c *gin.Context) uint {
// 与上游 BuildSignString 风格一致;body 以 SHA256 摘要参与签名(避免大 body 与特殊字符问题)。
// 参与签名的参数固定顺序为:app_key, body_sha256, method, nonce, path, timestamp。
func BuildOpenV1Sign(secret, appKey, timestamp, nonce, method, path string, body []byte) string {
bodyHash := sha256.Sum256(body)
content := strings.Join([]string{
"app_key=" + appKey,
"body_sha256=" + hex.EncodeToString(bodyHash[:]),
"body_sha256=" + signutil.SHA256Hex(body),
"method=" + strings.ToUpper(method),
"nonce=" + nonce,
"path=" + path,
"timestamp=" + timestamp,
}, "&")
return hmacSHA256Hex(secret, content)
return signutil.HMACSHA256Hex(secret, content)
}
// BuildSignString 保留旧接口的字典序签名算法,供上游发货兼容客户端和测试使用。
@@ -215,18 +210,5 @@ func BuildSignString(apiKey, timestamp, nonce, method, path, body string) string
// BuildOpenSign 供旧发货系统兼容使用。
func BuildOpenSign(apiKey, apiSecret, timestamp, nonce, method, path, body string) string {
return hmacSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}
func hmacSHA256Hex(secret, content string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(content))
return hex.EncodeToString(mac.Sum(nil))
}
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return value
return signutil.HMACSHA256Hex(apiSecret, BuildSignString(apiKey, timestamp, nonce, method, path, body))
}
@@ -2,15 +2,13 @@ package middleware
import (
"bytes"
"crypto/hmac"
"io"
"strconv"
"strings"
"time"
"affiliate_dash/internal/model"
"affiliate_dash/internal/pkg/openlog"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/pkg/signutil"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -73,8 +71,7 @@ func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc {
c.Abort()
return
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || abs64(time.Now().Unix()-ts) > cfg.SkewSeconds {
if !signutil.TimestampWithinSkew(timestamp, time.Duration(cfg.SkewSeconds)*time.Second) {
openlog.Warn(c, "source_open_auth expired ts=%s skew=%d", timestamp, cfg.SkewSeconds)
response.Unauthorized(c, "请求已过期或 X-Timestamp 格式错误")
c.Abort()
@@ -92,7 +89,7 @@ func SourceOpenAuth(cfg SourceOpenAuthConfig) gin.HandlerFunc {
c.Set(openlog.CtxBody, string(bodyBytes))
expected := BuildOpenSign(apiKey, cfg.APISecret, timestamp, nonce, c.Request.Method, c.Request.URL.Path, string(bodyBytes))
if !hmac.Equal([]byte(strings.ToLower(sign)), []byte(expected)) {
if !signutil.EqualHexSignatures(sign, expected) {
openlog.Warn(c, "source_open_auth sign_mismatch method=%s path=%s body=%s sign=%s expected=%s",
c.Request.Method, c.Request.URL.Path, openlog.Truncate(string(bodyBytes), 200),
openlog.MaskSign(sign), openlog.MaskSign(expected))
+11 -26
View File
@@ -1,10 +1,7 @@
package lewan
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/url"
@@ -12,6 +9,8 @@ import (
"strconv"
"strings"
"time"
"affiliate_dash/internal/pkg/signutil"
)
const (
@@ -114,11 +113,7 @@ func NormalizeQuery(rawQuery string) string {
// SHA256Hex returns the hex-encoded SHA-256 of the byte slice.
func SHA256Hex(body []byte) string {
if len(body) == 0 {
return EmptyBodySHA256
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
return signutil.SHA256Hex(body)
}
// BuildSigningString constructs the 6-line string to be signed:
@@ -150,9 +145,7 @@ func BuildSigningString(method, path, query, bodySHA256, timestamp, nonce string
// SignRequest computes the hex HMAC-SHA256 signature for a request.
func SignRequest(secretKey, signingString string) string {
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(signingString))
return hex.EncodeToString(mac.Sum(nil))
return signutil.HMACSHA256Hex(secretKey, signingString)
}
// GenerateNonce creates a random hex nonce of given byte length (e.g. 16 bytes = 32 hex chars).
@@ -165,10 +158,7 @@ func GenerateNonce() string {
// SignWebhook computes the v1= signature for an incoming/outgoing webhook.
func SignWebhook(secret, timestamp, eventID string, rawBody []byte) string {
prefix := timestamp + "." + eventID + "."
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(prefix))
mac.Write(rawBody)
return "v1=" + hex.EncodeToString(mac.Sum(nil))
return "v1=" + signutil.HMACSHA256HexBytes(secret, []byte(prefix), rawBody)
}
// VerifyWebhook validates a webhook signature with constant-time comparison and timestamp check.
@@ -176,23 +166,18 @@ func VerifyWebhook(secret, signature, timestamp, eventID string, rawBody []byte,
if secret == "" || signature == "" || timestamp == "" || eventID == "" {
return false
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
// 无论是否校验偏差,时间戳都必须是合法整数——保持既有契约不变。
if _, err := strconv.ParseInt(timestamp, 10, 64); err != nil {
return false
}
if allowedSkew > 0 {
diff := time.Now().Unix() - ts
if diff < 0 {
diff = -diff
}
if time.Duration(diff)*time.Second > allowedSkew {
return false
}
// allowedSkew <= 0 表示不校验时间窗口,因此这里自行守卫后再调用公共判断。
if allowedSkew > 0 && !signutil.TimestampWithinSkew(timestamp, allowedSkew) {
return false
}
expected := SignWebhook(secret, timestamp, eventID, rawBody)
actual := signature
if !strings.HasPrefix(actual, "v1=") {
actual = "v1=" + actual
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
return signutil.EqualSignatures(expected, actual)
}
@@ -0,0 +1,107 @@
package lewan
import (
"strconv"
"testing"
"time"
)
// 这些用例锁定 VerifyWebhook 在「共享原语重构」时被刻意保留的边界语义。
func TestVerifyWebhookTimestampContract(t *testing.T) {
const (
secret = "whsec_test"
eventID = "evt_1"
)
rawBody := []byte(`{"hello":"world"}`)
now := time.Now().Unix()
cases := []struct {
name string
timestamp string
allowedSkew time.Duration
want bool
}{
{
name: "allowedSkew 为 0 时仍要求时间戳可解析",
timestamp: "not-a-number",
allowedSkew: 0,
want: false,
},
{
name: "allowedSkew 为 0 时同一秒通过",
timestamp: strconv.FormatInt(now, 10),
allowedSkew: 0,
want: true,
},
{
name: "allowedSkew 为 0 时不校验时间窗口",
timestamp: strconv.FormatInt(now-86400, 10),
allowedSkew: 0,
want: true,
},
{
name: "指定窗口内通过",
timestamp: strconv.FormatInt(now-60, 10),
allowedSkew: 5 * time.Minute,
want: true,
},
{
name: "超出窗口被拒",
timestamp: strconv.FormatInt(now-600, 10),
allowedSkew: 5 * time.Minute,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sig := SignWebhook(secret, tc.timestamp, eventID, rawBody)
got := VerifyWebhook(secret, sig, tc.timestamp, eventID, rawBody, tc.allowedSkew)
if got != tc.want {
t.Fatalf("期望 %v,实际 %v", tc.want, got)
}
})
}
}
// 带不带 v1= 前缀都应被接受。
func TestVerifyWebhookAcceptsSignatureWithoutPrefix(t *testing.T) {
const (
secret = "whsec_test"
eventID = "evt_2"
)
rawBody := []byte(`{"a":1}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
full := SignWebhook(secret, timestamp, eventID, rawBody)
if !VerifyWebhook(secret, full, timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("带 v1= 前缀的签名应通过")
}
if !VerifyWebhook(secret, full[len("v1="):], timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("不带 v1= 前缀的签名也应通过")
}
}
// 任一必填字段为空都必须直接拒绝。
func TestVerifyWebhookRejectsEmptyInputs(t *testing.T) {
rawBody := []byte(`{}`)
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
const (
secret = "whsec_test"
eventID = "evt_3"
)
sig := SignWebhook(secret, timestamp, eventID, rawBody)
if VerifyWebhook("", sig, timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("空 secret 必须被拒")
}
if VerifyWebhook(secret, "", timestamp, eventID, rawBody, 5*time.Minute) {
t.Fatal("空签名必须被拒")
}
if VerifyWebhook(secret, sig, "", eventID, rawBody, 5*time.Minute) {
t.Fatal("空时间戳必须被拒")
}
if VerifyWebhook(secret, sig, timestamp, "", rawBody, 5*time.Minute) {
t.Fatal("空 eventID 必须被拒")
}
}
+82
View File
@@ -0,0 +1,82 @@
// 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))
}
@@ -0,0 +1,109 @@
package signutil
import (
"strconv"
"testing"
"time"
)
// TestHMACSHA256HexKnownVector 用公开测试向量锁定算法,避免以后「优化」时改坏。
func TestHMACSHA256HexKnownVector(t *testing.T) {
got := HMACSHA256Hex("key", "The quick brown fox jumps over the lazy dog")
const want = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
if got != want {
t.Fatalf("HMAC-SHA256 向量不符:期望 %s,实际 %s", want, got)
}
}
// TestHMACSHA256HexBytesMatchesStringVersion 分段写入必须与整体写入等价。
func TestHMACSHA256HexBytesMatchesStringVersion(t *testing.T) {
whole := HMACSHA256Hex("secret", "prefix-body-suffix")
parts := HMACSHA256HexBytes("secret", []byte("prefix-"), []byte("body"), []byte("-suffix"))
if whole != parts {
t.Fatalf("分段写入结果不一致:%s vs %s", whole, parts)
}
}
func TestHMACSHA256HexBytesNoParts(t *testing.T) {
// 空内容等价于对空串做 HMAC。
if got, want := HMACSHA256HexBytes("secret"), HMACSHA256Hex("secret", ""); got != want {
t.Fatalf("无分段应等价于空内容:%s vs %s", got, want)
}
}
func TestSHA256Hex(t *testing.T) {
if got := SHA256Hex(nil); got != EmptyBodySHA256 {
t.Fatalf("空内容应返回空串摘要常量,实际 %s", got)
}
if got := SHA256Hex([]byte{}); got != EmptyBodySHA256 {
t.Fatalf("空切片应返回空串摘要常量,实际 %s", got)
}
const wantAbc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if got := SHA256Hex([]byte("abc")); got != wantAbc {
t.Fatalf("SHA256(abc) 不符:期望 %s,实际 %s", wantAbc, got)
}
}
func TestTimestampWithinSkew(t *testing.T) {
now := time.Now().Unix()
cases := []struct {
name string
timestamp string
skew time.Duration
want bool
}{
{"当前时间在窗口内", itoa(now), 300 * time.Second, true},
{"边界上刚好等于 skew", itoa(now - 300), 300 * time.Second, true},
{"超出 skew 一秒", itoa(now - 301), 300 * time.Second, false},
{"未来时间同样按绝对值判断", itoa(now + 301), 300 * time.Second, false},
{"未来时间在窗口内", itoa(now + 60), 300 * time.Second, true},
{"非法时间戳", "not-a-number", 300 * time.Second, false},
{"空时间戳", "", 300 * time.Second, false},
{"不裁剪空白(保持既有行为)", " " + itoa(now) + " ", 300 * time.Second, false},
{"skew 为 0 时只有同一秒通过", itoa(now), 0, true},
{"skew 为 0 时上一秒不通过", itoa(now - 1), 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := TimestampWithinSkew(tc.timestamp, tc.skew); got != tc.want {
t.Fatalf("期望 %v,实际 %v", tc.want, got)
}
})
}
}
func TestTimestampWithinSkewSecondsMatchesDuration(t *testing.T) {
now := itoa(time.Now().Unix())
if TimestampWithinSkewSeconds(now, 300) != TimestampWithinSkew(now, 300*time.Second) {
t.Fatal("秒版本与 Duration 版本应一致")
}
}
func TestEqualSignatures(t *testing.T) {
if !EqualSignatures("abc123", "abc123") {
t.Fatal("相同签名应通过")
}
if EqualSignatures("abc123", "abc124") {
t.Fatal("不同签名不应通过")
}
if EqualSignatures("abc", "abcd") {
t.Fatal("长度不同的签名不应通过")
}
// 该函数不做大小写归一化,这是刻意为之(乐玩签名带 v1= 前缀,需原样比较)。
if EqualSignatures("ABC123", "abc123") {
t.Fatal("未归一化的版本应区分大小写")
}
}
func TestEqualHexSignatures(t *testing.T) {
if !EqualHexSignatures("ABC123DEF", "abc123def") {
t.Fatal("十六进制签名应大小写不敏感")
}
if EqualHexSignatures("abc123", "abc124") {
t.Fatal("不同签名不应通过")
}
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}