From a47b61a98d2303a6d1eb69dedd48bef0b7fb1926 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 6 Oct 2026 19:53:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(sign):=20=E6=8A=BD=E5=8F=96=E5=85=AC?= =?UTF-8?q?=E5=85=B1=E7=AD=BE=E5=90=8D=E5=8E=9F=E8=AF=AD=20signutil?= =?UTF-8?q?=EF=BC=8C=E4=B8=89=E5=A5=97=E7=AD=BE=E5=90=8D=E4=B8=B2=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E4=BF=9D=E6=8C=81=E4=B8=8D=E5=8F=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商户侧 /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 边界语义测试。 --- backend/internal/middleware/open_auth.go | 34 ++---- .../internal/middleware/source_open_auth.go | 9 +- backend/internal/pkg/lewan/signer.go | 37 ++---- .../internal/pkg/lewan/verify_webhook_test.go | 107 +++++++++++++++++ backend/internal/pkg/signutil/signutil.go | 82 +++++++++++++ .../internal/pkg/signutil/signutil_test.go | 109 ++++++++++++++++++ 6 files changed, 320 insertions(+), 58 deletions(-) create mode 100644 backend/internal/pkg/lewan/verify_webhook_test.go create mode 100644 backend/internal/pkg/signutil/signutil.go create mode 100644 backend/internal/pkg/signutil/signutil_test.go diff --git a/backend/internal/middleware/open_auth.go b/backend/internal/middleware/open_auth.go index 8ef7199..032edd5 100644 --- a/backend/internal/middleware/open_auth.go +++ b/backend/internal/middleware/open_auth.go @@ -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)) } diff --git a/backend/internal/middleware/source_open_auth.go b/backend/internal/middleware/source_open_auth.go index 639149a..acb4072 100644 --- a/backend/internal/middleware/source_open_auth.go +++ b/backend/internal/middleware/source_open_auth.go @@ -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)) diff --git a/backend/internal/pkg/lewan/signer.go b/backend/internal/pkg/lewan/signer.go index 0347676..9618358 100644 --- a/backend/internal/pkg/lewan/signer.go +++ b/backend/internal/pkg/lewan/signer.go @@ -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) } diff --git a/backend/internal/pkg/lewan/verify_webhook_test.go b/backend/internal/pkg/lewan/verify_webhook_test.go new file mode 100644 index 0000000..099ceaa --- /dev/null +++ b/backend/internal/pkg/lewan/verify_webhook_test.go @@ -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 必须被拒") + } +} diff --git a/backend/internal/pkg/signutil/signutil.go b/backend/internal/pkg/signutil/signutil.go new file mode 100644 index 0000000..e454487 --- /dev/null +++ b/backend/internal/pkg/signutil/signutil.go @@ -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)) +} diff --git a/backend/internal/pkg/signutil/signutil_test.go b/backend/internal/pkg/signutil/signutil_test.go new file mode 100644 index 0000000..d8e7826 --- /dev/null +++ b/backend/internal/pkg/signutil/signutil_test.go @@ -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) +}