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:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user