diff --git a/gateway/internal/vkpay/vkpay.go b/gateway/internal/vkpay/vkpay.go new file mode 100644 index 0000000..51c2b3b --- /dev/null +++ b/gateway/internal/vkpay/vkpay.go @@ -0,0 +1,40 @@ +// Package vkpay verifies VK Mini Apps payment ("голоса") callback signatures. VK signs each +// payment notification (get_item / order_status_change) and expects the receiving server to verify +// it with the app's secret before acting. The signature is MD5 — mandated by VK's payment protocol, +// not a security choice on our part — so this package uses crypto/md5 deliberately. +package vkpay + +import ( + "crypto/md5" + "encoding/hex" + "sort" + "strings" +) + +// Verify reports whether params carry a valid VK payment signature under secret. VK computes the +// signature as the MD5 of the sig-excluded parameters, sorted alphabetically by name and +// concatenated as key=value with no separators, with the app secret appended. The comparison is +// case-insensitive over the hex digest. +func Verify(params map[string]string, secret string) bool { + got := params["sig"] + if got == "" || secret == "" { + return false + } + keys := make([]string, 0, len(params)) + for k := range params { + if k == "sig" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k) + b.WriteString("=") + b.WriteString(params[k]) + } + b.WriteString(secret) + sum := md5.Sum([]byte(b.String())) + return strings.EqualFold(hex.EncodeToString(sum[:]), got) +} diff --git a/gateway/internal/vkpay/vkpay_test.go b/gateway/internal/vkpay/vkpay_test.go new file mode 100644 index 0000000..6f9450c --- /dev/null +++ b/gateway/internal/vkpay/vkpay_test.go @@ -0,0 +1,74 @@ +package vkpay + +import ( + "crypto/md5" + "encoding/hex" + "maps" + "sort" + "strings" + "testing" +) + +// vkSig computes a valid VK signature the way VK does, for the fixtures (sig excluded, sorted +// key=value concatenation, secret appended, MD5 hex). +func vkSig(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k := range params { + if k == "sig" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b.WriteString(k + "=" + params[k]) + } + b.WriteString(secret) + sum := md5.Sum([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +func clone(m map[string]string) map[string]string { + out := make(map[string]string, len(m)) + maps.Copy(out, m) + return out +} + +func TestVerify(t *testing.T) { + const secret = "app_secret" + base := map[string]string{ + "notification_type": "order_status_change", + "app_id": "123", + "user_id": "456", + "receiver_id": "456", + "order_id": "789", + "date": "1700000000", + "status": "chargeable", + "item": "019f47a0-6f9e-7000-8000-000000000000", + "item_price": "10", + } + valid := clone(base) + // VK sends the digest uppercase; the verifier must accept it case-insensitively. + valid["sig"] = strings.ToUpper(vkSig(base, secret)) + + if !Verify(valid, secret) { + t.Fatal("valid VK signature rejected") + } + + tampered := clone(valid) + tampered["item_price"] = "1" + if Verify(tampered, secret) { + t.Error("accepted a tampered amount") + } + + if Verify(valid, "wrong-secret") { + t.Error("accepted under the wrong secret") + } + + noSig := clone(valid) + delete(noSig, "sig") + if Verify(noSig, secret) { + t.Error("accepted a callback with no signature") + } +}