Files
affiliate_dash/backend/internal/pkg/lewan/signer.go
T
yml2213andCursor f7f48d0feb 功能:接入乐玩开放平台双渠道路由,并修复渠道锁定与 Webhook 可靠性
在保持 DLC 默认零破坏的前提下,支持 priority/仅乐玩/仅 DLC 履约策略与后台 GUI 配置;同步补齐 Prepare 渠道落库、乐玩订单取消、Webhook 验签与失败重试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 15:48:03 +08:00

199 lines
5.1 KiB
Go

package lewan
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
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 {
if len(body) == 0 {
return EmptyBodySHA256
}
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}
// 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 {
mac := hmac.New(sha256.New, []byte(secretKey))
mac.Write([]byte(signingString))
return hex.EncodeToString(mac.Sum(nil))
}
// 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 + "."
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(prefix))
mac.Write(rawBody)
return "v1=" + hex.EncodeToString(mac.Sum(nil))
}
// 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
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if 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
}
}
expected := SignWebhook(secret, timestamp, eventID, rawBody)
actual := signature
if !strings.HasPrefix(actual, "v1=") {
actual = "v1=" + actual
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}