Files
affiliate_dash/backend/internal/pkg/signutil/signutil.go
T
yml2213 a47b61a98d 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 边界语义测试。
2026-10-06 19:53:55 +08:00

83 lines
3.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}