This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BuildStringToSign StringToSign = METHOD\nPATH\nCANONICAL_QUERY\nBODY_SHA256\nX-App-Id\nX-Timestamp\nX-Nonce
|
||||
func BuildStringToSign(method, path, rawQuery string, body []byte, appId, timestamp, nonce string) string {
|
||||
canonical := canonicalQuery(rawQuery)
|
||||
bodyHash := sha256Hex(body)
|
||||
parts := []string{
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
canonical,
|
||||
bodyHash,
|
||||
appId,
|
||||
timestamp,
|
||||
nonce,
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func canonicalQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
values, err := url.ParseQuery(rawQuery)
|
||||
if err != nil {
|
||||
return rawQuery
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", key, values.Get(key)))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package signature
|
||||
|
||||
type SignatureConf struct {
|
||||
AppSecrets map[string]string `yaml:"AppSecrets"`
|
||||
ValidWindowSeconds int64 `yaml:"ValidWindowSeconds"`
|
||||
SkipPrefixes []string `yaml:"SkipPrefixes"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package signature
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrSignatureMissing = errors.New("signature missing")
|
||||
ErrSignatureExpired = errors.New("signature expired")
|
||||
ErrSignatureInvalid = errors.New("signature invalid")
|
||||
ErrSignatureReplay = errors.New("signature replay")
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
type NonceStore interface {
|
||||
SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error)
|
||||
}
|
||||
|
||||
type RedisNonceStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisNonceStore(client *redis.Client) *RedisNonceStore {
|
||||
return &RedisNonceStore{client: client}
|
||||
}
|
||||
|
||||
func (s *RedisNonceStore) SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error) {
|
||||
key := fmt.Sprintf("sig:nonce:%s:%s", appId, nonce)
|
||||
ok, err := s.client.SetNX(ctx, key, "1", time.Duration(ttlSeconds)*time.Second).Result()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !ok, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type mockNonceStore struct {
|
||||
seen map[string]bool
|
||||
}
|
||||
|
||||
func newMockNonceStore() *mockNonceStore {
|
||||
return &mockNonceStore{seen: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (m *mockNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) {
|
||||
key := appId + ":" + nonce
|
||||
if m.seen[key] {
|
||||
return true, nil
|
||||
}
|
||||
m.seen[key] = true
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func makeSignature(secret, stringToSign string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func TestValidateSuccess(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockNonceStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateExpired(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockNonceStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix()-400, 10)
|
||||
nonce := "abc"
|
||||
sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureExpired {
|
||||
t.Fatalf("expected ErrSignatureExpired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReplay(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockNonceStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "same-nonce-replay"
|
||||
sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce)
|
||||
sig := makeSignature("uB4G,XxL2{7b", sts)
|
||||
|
||||
_ = v.Validate(context.Background(), "web-client", ts, nonce, sig, sts)
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureReplay {
|
||||
t.Fatalf("expected ErrSignatureReplay, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateInvalidSignature(t *testing.T) {
|
||||
conf := SignatureConf{
|
||||
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
|
||||
ValidWindowSeconds: 300,
|
||||
}
|
||||
v := NewValidator(conf, newMockNonceStore())
|
||||
|
||||
ts := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := "nonce-invalid-sig"
|
||||
sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
|
||||
|
||||
if err := v.Validate(context.Background(), "web-client", ts, nonce, "badsignature", sts); err != ErrSignatureInvalid {
|
||||
t.Fatalf("expected ErrSignatureInvalid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStringToSignCanonicalQuery(t *testing.T) {
|
||||
got := BuildStringToSign(
|
||||
"get",
|
||||
"/v1/public/order/list",
|
||||
"b=2&a=1&a=3&c=",
|
||||
nil,
|
||||
"web-client",
|
||||
"1700000000",
|
||||
"nonce-1",
|
||||
)
|
||||
|
||||
want := "GET\n/v1/public/order/list\na=1&b=2&c=\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nweb-client\n1700000000\nnonce-1"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected stringToSign\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package signature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Validator struct {
|
||||
conf SignatureConf
|
||||
nonceStore NonceStore
|
||||
}
|
||||
|
||||
func NewValidator(conf SignatureConf, store NonceStore) *Validator {
|
||||
return &Validator{
|
||||
conf: conf,
|
||||
nonceStore: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) windowSeconds() int64 {
|
||||
if v.conf.ValidWindowSeconds <= 0 {
|
||||
return 300
|
||||
}
|
||||
return v.conf.ValidWindowSeconds
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(ctx context.Context, appId, timestamp, nonce, signature, stringToSign string) error {
|
||||
secret, ok := v.conf.AppSecrets[appId]
|
||||
if !ok {
|
||||
return ErrSignatureMissing
|
||||
}
|
||||
|
||||
ts, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil {
|
||||
return ErrSignatureExpired
|
||||
}
|
||||
diff := time.Now().Unix() - ts
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
if diff > v.windowSeconds() {
|
||||
return ErrSignatureExpired
|
||||
}
|
||||
|
||||
replayed, err := v.nonceStore.SetIfNotExists(ctx, appId, nonce, v.windowSeconds()+60)
|
||||
if err == nil && replayed {
|
||||
return ErrSignatureReplay
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(signature)) {
|
||||
return ErrSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -58,6 +58,10 @@ const (
|
||||
InvalidAccess uint32 = 40005
|
||||
InvalidCiphertext uint32 = 40006
|
||||
SecretIsEmpty uint32 = 40007
|
||||
SignatureMissing uint32 = 40008
|
||||
SignatureExpired uint32 = 40009
|
||||
SignatureInvalid uint32 = 40010
|
||||
SignatureReplay uint32 = 40011
|
||||
)
|
||||
|
||||
//coupon error
|
||||
|
||||
@@ -17,6 +17,10 @@ func init() {
|
||||
SecretIsEmpty: "Secret is empty",
|
||||
InvalidAccess: "Invalid access",
|
||||
InvalidCiphertext: "Invalid ciphertext",
|
||||
SignatureMissing: "Signature headers are missing",
|
||||
SignatureExpired: "Signature is expired",
|
||||
SignatureInvalid: "Signature is invalid",
|
||||
SignatureReplay: "Signature nonce replay detected",
|
||||
// Database error
|
||||
DatabaseQueryError: "Database query error",
|
||||
DatabaseUpdateError: "Database update error",
|
||||
|
||||
Reference in New Issue
Block a user