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) }