Самодостаточная реализация (только stdlib). Алгоритм и спецификация — Подпись запросов. Подпись побайтно совпадает с JS, Python и PHP.
package main
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"yourmodule/signature" // код из спойлера ниже
)
func main() {
rawURL := "https://api.idynsys.org/api/billing/payment-methods"
body := []byte(`{"amount":20,"currency":"RUB"}`) // ровно те байты, что уйдут в запрос
secret := []byte("app_secret_key")
// path + rawQuery из URL (split по первому "?"; NormalizePath примет и полный URL)
path, rawQuery := rawURL, ""
if i := strings.IndexByte(path, '?'); i >= 0 {
rawQuery, path = path[i+1:], path[:i]
}
req := signature.Request{
HTTPMethod: "POST",
Path: path,
RawQuery: rawQuery,
Body: body,
MerchantClientID: "merchant_1",
Timestamp: strconv.FormatInt(time.Now().Unix(), 10),
Nonce: uuid.NewString(),
}
sig, err := signature.Sign(req, secret)
if err != nil {
panic(err)
}
headers := map[string]string{
signature.HeaderClientID: req.MerchantClientID,
signature.HeaderTimestamp: req.Timestamp,
signature.HeaderNonce: req.Nonce,
signature.HeaderSignature: sig,
}
fmt.Println(headers)
}
Sign хэширует Body как есть — отправляйте те же байты, которые подписали (никакой нормализации не требуется). Внутри стека IDS то же самое доступно в библиотеке ids-libraries (пакет подписи).
// Package signature — самодостаточная (только stdlib) реализация схемы подписи
// внешних запросов. Алгоритм идентичен серверной проверке.
//
// stringToSign = METHOD + "\n" +
// PATH + "\n" +
// CANONICAL_QUERY + "\n" +
// HEX(SHA256(raw_body)) + "\n" +
// MERCHANT_CLIENT_ID + "\n" +
// TIMESTAMP + "\n" +
// NONCE
//
// signature = HEX(HMAC_SHA256(stringToSign, secret))
package signature
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"unicode/utf8"
)
const (
HeaderSignature = "X-Signature"
HeaderTimestamp = "X-Timestamp"
HeaderNonce = "X-Nonce"
HeaderClientID = "X-Client-Id"
)
var (
ErrEmptyMethod = errors.New("sign: method is empty")
ErrEmptyClientID = errors.New("sign: client id is empty")
ErrEmptyTimestamp = errors.New("sign: timestamp is empty")
ErrEmptyNonce = errors.New("sign: nonce is empty")
ErrPathInvalidChar = errors.New("sign: path contains invalid character")
ErrPathNoLeadingSlash = errors.New("sign: path must start with /")
ErrPathDotSegment = errors.New("sign: path contains dotted segment")
ErrQueryEmptyKey = errors.New("sign: query parameter has empty key")
ErrQueryEmptyFragment = errors.New("sign: query has empty fragment between '&'")
ErrQueryInvalidEncoding = errors.New("sign: invalid percent-encoding in query")
ErrQueryInvalidUTF8 = errors.New("sign: query is not valid UTF-8 after percent-decoding")
)
// Request — входные данные для подписи.
type Request struct {
HTTPMethod string // GET / POST / ...
Path string // path-часть URL (можно передать полный URL — извлечётся path)
RawQuery string // часть после "?", без "?"
Body []byte // raw body
MerchantClientID string // X-Client-Id
Timestamp string // X-Timestamp (UTC unix seconds)
Nonce string // X-Nonce
}
// Sign возвращает значение для заголовка X-Signature.
func Sign(r Request, secret []byte) (string, error) {
sts, err := BuildStringToSign(r)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(sts))
return hex.EncodeToString(mac.Sum(nil)), nil
}
// BuildStringToSign собирает каноническую строку (полезно для отладки).
func BuildStringToSign(r Request) (string, error) {
if r.HTTPMethod == "" {
return "", ErrEmptyMethod
}
if r.MerchantClientID == "" {
return "", ErrEmptyClientID
}
if r.Timestamp == "" {
return "", ErrEmptyTimestamp
}
if r.Nonce == "" {
return "", ErrEmptyNonce
}
path, err := NormalizePath(r.Path)
if err != nil {
return "", err
}
canonQuery, err := CanonicalizeQuery(r.RawQuery)
if err != nil {
return "", err
}
bodyHash := SHA256Hex(r.Body)
return strings.Join([]string{
strings.ToUpper(r.HTTPMethod),
path,
canonQuery,
bodyHash,
r.MerchantClientID,
r.Timestamp,
r.Nonce,
}, "\n"), nil
}
func SHA256Hex(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// ---------------------------------------------------------------------
// PATH
// ---------------------------------------------------------------------
func NormalizePath(rawPath string) (string, error) {
if rawPath == "" {
return "/", nil
}
p := rawPath
if i := strings.Index(p, "://"); i >= 0 {
rest := p[i+3:]
if slash := strings.IndexByte(rest, '/'); slash >= 0 {
p = rest[slash:]
} else {
return "/", nil
}
}
if i := strings.IndexByte(p, '?'); i >= 0 {
p = p[:i]
}
if i := strings.IndexByte(p, '#'); i >= 0 {
p = p[:i]
}
if p == "" {
return "/", nil
}
if !strings.HasPrefix(p, "/") {
return "", ErrPathNoLeadingSlash
}
for _, seg := range strings.Split(p, "/") {
if seg == "." || seg == ".." {
return "", fmt.Errorf("%w: %q", ErrPathDotSegment, seg)
}
}
for i := 0; i < len(p); i++ {
if !isAllowedPathChar(p[i]) {
return "", fmt.Errorf("%w: %q at index %d", ErrPathInvalidChar, string(p[i]), i)
}
}
for strings.Contains(p, "//") {
p = strings.ReplaceAll(p, "//", "/")
}
if len(p) > 1 && strings.HasSuffix(p, "/") {
p = strings.TrimRight(p, "/")
}
if p == "" {
return "/", nil
}
return p, nil
}
func isAllowedPathChar(c byte) bool {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
return true
case c == '/' || c == '-' || c == '_':
return true
}
return false
}
// ---------------------------------------------------------------------
// QUERY
// ---------------------------------------------------------------------
func CanonicalizeQuery(rawQuery string) (string, error) {
rawQuery = strings.TrimPrefix(rawQuery, "?")
if i := strings.IndexByte(rawQuery, '#'); i >= 0 {
rawQuery = rawQuery[:i]
}
if rawQuery == "" {
return "", nil
}
type pair struct{ k, v string }
parts := strings.Split(rawQuery, "&")
pairs := make([]pair, 0, len(parts))
for _, part := range parts {
if part == "" {
return "", ErrQueryEmptyFragment
}
var rawKey, rawVal string
if idx := strings.IndexByte(part, '='); idx >= 0 {
rawKey, rawVal = part[:idx], part[idx+1:]
} else {
rawKey = part
}
if rawKey == "" {
return "", ErrQueryEmptyKey
}
decKey, err := percentDecode(rawKey)
if err != nil {
return "", err
}
decVal, err := percentDecode(rawVal)
if err != nil {
return "", err
}
if !utf8.ValidString(decKey) || !utf8.ValidString(decVal) {
return "", ErrQueryInvalidUTF8
}
pairs = append(pairs, pair{percentEncode(decKey), percentEncode(decVal)})
}
sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].k == pairs[j].k {
return pairs[i].v < pairs[j].v
}
return pairs[i].k < pairs[j].k
})
var sb strings.Builder
for i, p := range pairs {
if i > 0 {
sb.WriteByte('&')
}
sb.WriteString(p.k)
sb.WriteByte('=')
sb.WriteString(p.v)
}
return sb.String(), nil
}
func percentDecode(s string) (string, error) {
out, err := url.QueryUnescape(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrQueryInvalidEncoding, err)
}
return out, nil
}
func percentEncode(s string) string {
var sb strings.Builder
sb.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if isUnreservedByte(c) {
sb.WriteByte(c)
} else {
fmt.Fprintf(&sb, "%%%02X", c)
}
}
return sb.String()
}
func isUnreservedByte(c byte) bool {
switch {
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9':
return true
case c == '-' || c == '.' || c == '_' || c == '~':
return true
}
return false
}
go test. Reference-векторы идентичны эталонам на других языках (секрет krionyx_secret_key).
package signature
import "testing"
const testSecret = "krionyx_secret_key"
func TestReferenceVectors(t *testing.T) {
tests := []struct {
name string
req Request
want string
}{
{"simple_get_with_query", Request{HTTPMethod: "GET", Path: "/api/billing/payment-methods", RawQuery: "amount=20&hash=abc&paymentType=deposit", MerchantClientID: "merchant_1", Timestamp: "1730390400", Nonce: "550e8400-e29b-41d4-a716-446655440000"}, "b91ccfa5a51b7438b426c4db1326f56869fe90dfd7070b0bc933e86fdee62902"},
{"post_json_body", Request{HTTPMethod: "POST", Path: "/api/accounts/external-app/deposit", Body: []byte(`{"amount":2004,"currency":"RUB"}`), MerchantClientID: "merchant_1", Timestamp: "1730390400", Nonce: "b2c3d4e5-f6a7-8901-bcde-f12345678901"}, "45ddf408562b8f0d5a549cb19dc092d88d7ea55969f28c05d113c859803ea921"},
{"lowercase_method_uppercased", Request{HTTPMethod: "post", Path: "/api/x", MerchantClientID: "m", Timestamp: "0", Nonce: "n"}, "d174dcaeed0a08b0d3297039c7863ce190cc50b0827ec95fcba21923cc2b8169"},
{"unicode_body", Request{HTTPMethod: "POST", Path: "/api/text", Body: []byte(`{"text":"Привет мир!"}`), MerchantClientID: "merch", Timestamp: "1700000000", Nonce: "nonce-1"}, "011366b50761f86384b65b78a00539915b5f06f701dd18c5e0f689d9a19409da"},
{"html_chars_in_body", Request{HTTPMethod: "POST", Path: "/api/text", Body: []byte(`{"html":"<div>&my test</div>"}`), MerchantClientID: "merch", Timestamp: "1700000000", Nonce: "nonce-2"}, "7e2800e716c1cf75b166d19bf41f3d287a1858da5c2c26a84fe7963d84827029"},
{"messy_path_and_query", Request{HTTPMethod: "GET", Path: "///api//withdrawal///status/", RawQuery: "b=2&a=hello+world", MerchantClientID: "m", Timestamp: "1700000000", Nonce: "n"}, "a33f50abeb513a6ba7b5989ca6ed034b6792a3c91e69db14a03c0cb21d863cfa"},
{"repeated_query_params", Request{HTTPMethod: "GET", Path: "/list", RawQuery: "tag=b&tag=a&empty=&flag", MerchantClientID: "m", Timestamp: "1700000000", Nonce: "n"}, "0661370163a7911271d58d428df900dac16d9207c4a1a9aa5c3d055165f5b0d6"},
{"put_with_body", Request{HTTPMethod: "PUT", Path: "/api/orders/123", Body: []byte(`{"status":"approved","amount":100.50}`), MerchantClientID: "m", Timestamp: "1700000000", Nonce: "n"}, "b267f362214607515fc6f62238bfe7f7f5e888196077b9cb22f35102e685fc4c"},
{"delete_with_query", Request{HTTPMethod: "DELETE", Path: "/api/orders/123", RawQuery: "reason=duplicate&forced=true", MerchantClientID: "m", Timestamp: "1700000000", Nonce: "n"}, "bd4bd8e778441c0a93c6fb0be354a3f8a0d76bb46e34b6abeb4ec700700c6d4d"},
{"empty_body_root_path", Request{HTTPMethod: "GET", Path: "/", MerchantClientID: "cl", Timestamp: "1", Nonce: "u"}, "a628409be660a17ddc18e3aa79111a7a1b6c46ab8cc37734b7211b8c9326bfb7"},
{"mixed_unicode_html_body", Request{HTTPMethod: "POST", Path: "/api/payment", RawQuery: "v=1", Body: []byte(`{"text":"тест & проверка 1 <ok>","amount":100.50}`), MerchantClientID: "merchant_xyz", Timestamp: "1730390400", Nonce: "nn"}, "07714c6d4bb71fe162bbba1f616d7e9f73effc148b74ffaff07604c7e8bbe7a5"},
{"numeric_ids", Request{HTTPMethod: "POST", Path: "/api/v2/external-app/withdraw", Body: []byte(`{"id":12345,"amount":1000}`), MerchantClientID: "42", Timestamp: "1730390400", Nonce: "abc-123"}, "af8fe442f768927920b1502800282de5ec6db60dbc4903dce45214b8ae7f04da"},
// тело с whitespace хэшируется СЫРЫМ (без компактизации)
{"non_compact_body", Request{HTTPMethod: "POST", Path: "/api/x", Body: []byte(`{ "x": 1 }`), MerchantClientID: "m", Timestamp: "1700000000", Nonce: "n"}, "8b5e71fc75d6b83a6b316a973ba881f3d7044c5eb55153622e66672b851f7b35"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Sign(tt.req, []byte(testSecret))
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("mismatch\n want %s\n got %s", tt.want, got)
}
})
}
}
func TestNormalizePath(t *testing.T) {
ok := map[string]string{
"": "/",
"/": "/",
"//////": "/",
"/api/": "/api",
"/api////": "/api",
"/api//v1": "/api/v1",
"///api//withdrawal///status//": "/api/withdrawal/status",
"/Callback": "/Callback",
"https://example.com/api/x": "/api/x",
"http://example.com:8080/api/v1/users/": "/api/v1/users",
"https://example.com": "/",
"/api?q=1#frag": "/api",
}
for in, want := range ok {
if got, err := NormalizePath(in); err != nil || got != want {
t.Errorf("NormalizePath(%q) = %q, %v; want %q", in, got, err, want)
}
}
bad := []string{"api/v1", "/api/./status", "/api/../x", "/.", "/..", "/api.json", "/api%2Ftest", "/апи", "/api with space", "/api:8080"}
for _, in := range bad {
if _, err := NormalizePath(in); err == nil {
t.Errorf("NormalizePath(%q): expected error", in)
}
}
}
func TestCanonicalizeQuery(t *testing.T) {
ok := map[string]string{
"": "",
"b=2&a=hello+world": "a=hello%20world&b=2",
"tag=b&tag=a&empty=&flag": "empty=&flag=&tag=a&tag=b",
"z=9&b=2&a=3&a=10&a=2": "a=10&a=2&a=3&b=2&z=9",
"q=hello+world": "q=hello%20world",
"p=a%2fb": "p=a%2Fb",
"city=Москва": "city=%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B0",
"?a=1&b=2#frag": "a=1&b=2",
}
for in, want := range ok {
if got, err := CanonicalizeQuery(in); err != nil || got != want {
t.Errorf("CanonicalizeQuery(%q) = %q, %v; want %q", in, got, err, want)
}
}
bad := []string{"=value", "a=1&&b=2", "&a=1", "a=1&", "q=%ZZ", "a=%", "a=%C0", "a=%C0%80"}
for _, in := range bad {
if _, err := CanonicalizeQuery(in); err == nil {
t.Errorf("CanonicalizeQuery(%q): expected error", in)
}
}
}