feat(gateway): VK payment callback signature verifier

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.
This commit is contained in:
Ilia Denisov
2026-07-09 18:56:00 +02:00
parent 96adf98e1d
commit 3e0763463f
2 changed files with 114 additions and 0 deletions
+40
View File
@@ -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)
}
+74
View File
@@ -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")
}
}