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:
@@ -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 必须被拒")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user