商户侧 /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 边界语义测试。
184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
package lewan
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/pkg/signutil"
|
|
)
|
|
|
|
const (
|
|
EmptyBodySHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
HeaderAccessKey = "X-OpenAPI-Access-Key"
|
|
HeaderTimestamp = "X-OpenAPI-Timestamp"
|
|
HeaderNonce = "X-OpenAPI-Nonce"
|
|
HeaderSignature = "X-OpenAPI-Signature"
|
|
HeaderRequestID = "X-Request-Id"
|
|
|
|
HeaderWebhookAppID = "X-OpenAPI-Application-Id"
|
|
HeaderWebhookEventID = "X-OpenAPI-Event-Id"
|
|
HeaderWebhookSchemaVer = "X-OpenAPI-Schema-Version"
|
|
HeaderWebhookTimestamp = "X-OpenAPI-Timestamp"
|
|
HeaderWebhookSignature = "X-OpenAPI-Signature"
|
|
)
|
|
|
|
// RFC3986Escape encodes a string according to RFC 3986.
|
|
// Space is encoded as %20 (not +).
|
|
func RFC3986Escape(s string) string {
|
|
var b strings.Builder
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if isRFC3986Unreserved(c) {
|
|
b.WriteByte(c)
|
|
} else {
|
|
fmt.Fprintf(&b, "%%%02X", c)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func isRFC3986Unreserved(b byte) bool {
|
|
return (b >= 'a' && b <= 'z') ||
|
|
(b >= 'A' && b <= 'Z') ||
|
|
(b >= '0' && b <= '9') ||
|
|
b == '-' || b == '_' || b == '.' || b == '~'
|
|
}
|
|
|
|
type queryPair struct {
|
|
encodedKey string
|
|
encodedVal string
|
|
}
|
|
|
|
// NormalizeQuery parses a raw query string and normalizes it according to Lewan OpenAPI V1 rules:
|
|
// RFC 3986 encoded key and value, sorted by encoded key then encoded value, joined by '&'.
|
|
func NormalizeQuery(rawQuery string) string {
|
|
if strings.TrimSpace(rawQuery) == "" {
|
|
return ""
|
|
}
|
|
parts := strings.Split(rawQuery, "&")
|
|
var pairs []queryPair
|
|
for _, part := range parts {
|
|
if part == "" {
|
|
continue
|
|
}
|
|
var rawKey, rawVal string
|
|
idx := strings.IndexByte(part, '=')
|
|
if idx >= 0 {
|
|
rawKey = part[:idx]
|
|
rawVal = part[idx+1:]
|
|
} else {
|
|
rawKey = part
|
|
rawVal = ""
|
|
}
|
|
// Unescape first if percent-encoded, to canonicalize
|
|
decKey, err := url.QueryUnescape(strings.ReplaceAll(rawKey, "+", "%2B"))
|
|
if err != nil {
|
|
decKey = rawKey
|
|
}
|
|
decVal, err := url.QueryUnescape(strings.ReplaceAll(rawVal, "+", "%2B"))
|
|
if err != nil {
|
|
decVal = rawVal
|
|
}
|
|
|
|
pairs = append(pairs, queryPair{
|
|
encodedKey: RFC3986Escape(decKey),
|
|
encodedVal: RFC3986Escape(decVal),
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(pairs, func(i, j int) bool {
|
|
if pairs[i].encodedKey != pairs[j].encodedKey {
|
|
return pairs[i].encodedKey < pairs[j].encodedKey
|
|
}
|
|
return pairs[i].encodedVal < pairs[j].encodedVal
|
|
})
|
|
|
|
var sb strings.Builder
|
|
for i, p := range pairs {
|
|
if i > 0 {
|
|
sb.WriteByte('&')
|
|
}
|
|
sb.WriteString(p.encodedKey)
|
|
sb.WriteByte('=')
|
|
sb.WriteString(p.encodedVal)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// SHA256Hex returns the hex-encoded SHA-256 of the byte slice.
|
|
func SHA256Hex(body []byte) string {
|
|
return signutil.SHA256Hex(body)
|
|
}
|
|
|
|
// BuildSigningString constructs the 6-line string to be signed:
|
|
// 1. HTTP_METHOD (UPPERCASE)
|
|
// 2. PATH
|
|
// 3. RFC3986_QUERY
|
|
// 4. SHA256_HEX_OF_RAW_BODY
|
|
// 5. UNIX_TIMESTAMP_SECONDS
|
|
// 6. NONCE
|
|
func BuildSigningString(method, path, query, bodySHA256, timestamp, nonce string) string {
|
|
method = strings.ToUpper(strings.TrimSpace(method))
|
|
path = strings.TrimSpace(path)
|
|
normQuery := NormalizeQuery(query)
|
|
if bodySHA256 == "" {
|
|
bodySHA256 = EmptyBodySHA256
|
|
}
|
|
timestamp = strings.TrimSpace(timestamp)
|
|
nonce = strings.TrimSpace(nonce)
|
|
|
|
return strings.Join([]string{
|
|
method,
|
|
path,
|
|
normQuery,
|
|
bodySHA256,
|
|
timestamp,
|
|
nonce,
|
|
}, "\n")
|
|
}
|
|
|
|
// SignRequest computes the hex HMAC-SHA256 signature for a request.
|
|
func SignRequest(secretKey, signingString string) string {
|
|
return signutil.HMACSHA256Hex(secretKey, signingString)
|
|
}
|
|
|
|
// GenerateNonce creates a random hex nonce of given byte length (e.g. 16 bytes = 32 hex chars).
|
|
func GenerateNonce() string {
|
|
b := make([]byte, 16)
|
|
_, _ = rand.Read(b)
|
|
return "n" + hex.EncodeToString(b)[1:] // ensure leading char is letter/number
|
|
}
|
|
|
|
// SignWebhook computes the v1= signature for an incoming/outgoing webhook.
|
|
func SignWebhook(secret, timestamp, eventID string, rawBody []byte) string {
|
|
prefix := timestamp + "." + eventID + "."
|
|
return "v1=" + signutil.HMACSHA256HexBytes(secret, []byte(prefix), rawBody)
|
|
}
|
|
|
|
// VerifyWebhook validates a webhook signature with constant-time comparison and timestamp check.
|
|
func VerifyWebhook(secret, signature, timestamp, eventID string, rawBody []byte, allowedSkew time.Duration) bool {
|
|
if secret == "" || signature == "" || timestamp == "" || eventID == "" {
|
|
return false
|
|
}
|
|
// 无论是否校验偏差,时间戳都必须是合法整数——保持既有契约不变。
|
|
if _, err := strconv.ParseInt(timestamp, 10, 64); err != nil {
|
|
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 signutil.EqualSignatures(expected, actual)
|
|
}
|