From 3e0763463f6bae37b8a37c902adf9a42620c20c4 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 18:56:00 +0200 Subject: [PATCH] feat(gateway): VK payment callback signature verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the vkpay package: verify a VK Mini Apps payment ("голоса") callback signature — MD5 (mandated by VK's payment protocol) of the sig-excluded parameters, sorted by name and concatenated key=value, with the app secret appended; case-insensitive over the hex digest. Unit-tested (valid / tampered / wrong-secret / missing). First piece of the VK rail; the two-phase callback handler, the VK order branch and the client bridge follow. --- gateway/internal/vkpay/vkpay.go | 40 +++++++++++++++ gateway/internal/vkpay/vkpay_test.go | 74 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 gateway/internal/vkpay/vkpay.go create mode 100644 gateway/internal/vkpay/vkpay_test.go 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") + } +}