商户侧 /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 边界语义测试。
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
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)
|
|
}
|