Реализация на чистой стандартной библиотеке (hashlib, hmac), Python 3.8+. Алгоритм и спецификация — Подпись запросов. Подпись побайтно совпадает с Go, JS и PHP.
import time, uuid
from signature import sign, HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_CLIENT_ID
url = "https://api.idynsys.org/api/billing/payment-methods"
body = '{"amount":20,"currency":"RUB"}' # ровно те байты, что уйдут в запрос
secret = "app_secret_key"
# path + rawQuery из URL (split по первому "?"; normalize_path примет и полный URL)
path, _, raw_query = url.partition("?")
req = {
"method": "POST",
"path": path,
"rawQuery": raw_query,
"body": body,
"clientId": "merchant_1",
"timestamp": str(int(time.time())),
"nonce": str(uuid.uuid4()),
}
headers = {
HEADER_CLIENT_ID: req["clientId"],
HEADER_TIMESTAMP: req["timestamp"],
HEADER_NONCE: req["nonce"],
HEADER_SIGNATURE: sign(req, secret),
}
# отправляете body и headers тем же HTTP-клиентом (requests/httpx/...)
body хэшируется в сыром виде — sha256 от тех же байт, что уйдут в запрос, без какой-либо нормализации. Передавайте в sign и в реальный запрос одну и ту же строку.
"""
Реализация схемы подписи внешних запросов.
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))
Зависит только от стандартной библиотеки. Python 3.8+.
"""
import hashlib
import hmac
from typing import Optional, Union
HEADER_SIGNATURE = "X-Signature"
HEADER_TIMESTAMP = "X-Timestamp"
HEADER_NONCE = "X-Nonce"
HEADER_CLIENT_ID = "X-Client-Id"
class SignError(ValueError):
"""Ошибка построения канонической строки/подписи."""
# ---------- Path normalization ----------
def _is_allowed_path_byte(c: int) -> bool:
return (
0x41 <= c <= 0x5A or 0x61 <= c <= 0x7A or 0x30 <= c <= 0x39
or c == 0x2F or c == 0x2D or c == 0x5F
)
def normalize_path(raw_path: Optional[str]) -> str:
if raw_path is None or raw_path == "":
return "/"
p = str(raw_path)
sch = p.find("://")
if sch >= 0:
rest = p[sch + 3:]
slash = rest.find("/")
if slash >= 0:
p = rest[slash:]
else:
return "/"
qi = p.find("?")
if qi >= 0:
p = p[:qi]
fi = p.find("#")
if fi >= 0:
p = p[:fi]
if p == "":
return "/"
if not p.startswith("/"):
raise SignError("sign: path must start with /")
for seg in p.split("/"):
if seg == "." or seg == "..":
raise SignError("sign: path contains dotted segment: %r" % seg)
for i, ch in enumerate(p):
if not _is_allowed_path_byte(ord(ch)):
raise SignError(
"sign: path contains invalid character: %r at index %d" % (ch, i)
)
while "//" in p:
p = p.replace("//", "/")
if len(p) > 1 and p.endswith("/"):
p = p.rstrip("/")
return p or "/"
# ---------- Query canonicalization ----------
def _is_unreserved_byte(b: int) -> bool:
return (
0x41 <= b <= 0x5A or 0x61 <= b <= 0x7A or 0x30 <= b <= 0x39
or b == 0x2D or b == 0x2E or b == 0x5F or b == 0x7E
)
def percent_encode(s: str) -> str:
out = []
for b in s.encode("utf-8"):
if _is_unreserved_byte(b):
out.append(chr(b))
else:
out.append("%%%02X" % b)
return "".join(out)
def _unhex_nibble(c: str) -> int:
o = ord(c)
if 0x30 <= o <= 0x39:
return o - 0x30
if 0x41 <= o <= 0x46:
return o - 0x41 + 10
if 0x61 <= o <= 0x66:
return o - 0x61 + 10
return -1
def percent_decode(s: str) -> str:
data = bytearray()
i, n = 0, len(s)
while i < n:
c = s[i]
if c == "%":
if i + 2 >= n:
raise SignError("sign: invalid percent-encoding in query")
hi = _unhex_nibble(s[i + 1])
lo = _unhex_nibble(s[i + 2])
if hi < 0 or lo < 0:
raise SignError("sign: invalid percent-encoding in query")
data.append((hi << 4) | lo)
i += 3
continue
if c == "+":
data.append(0x20)
elif ord(c) > 0x7F:
data.extend(c.encode("utf-8"))
else:
data.append(ord(c))
i += 1
try:
return data.decode("utf-8")
except UnicodeDecodeError:
raise SignError(
"sign: query is not valid UTF-8 after percent-decoding"
) from None
def canonicalize_query(raw_query: Optional[str]) -> str:
if not raw_query:
return ""
q = str(raw_query)
if q.startswith("?"):
q = q[1:]
hi = q.find("#")
if hi >= 0:
q = q[:hi]
if q == "":
return ""
pairs = []
for part in q.split("&"):
if part == "":
raise SignError("sign: query has empty fragment between '&'")
eq = part.find("=")
if eq >= 0:
raw_key, raw_val = part[:eq], part[eq + 1:]
else:
raw_key, raw_val = part, ""
if raw_key == "":
raise SignError("sign: query parameter has empty key")
pairs.append(
(percent_encode(percent_decode(raw_key)),
percent_encode(percent_decode(raw_val)))
)
pairs.sort(key=lambda kv: (kv[0], kv[1]))
return "&".join("%s=%s" % (k, v) for k, v in pairs)
# ---------- Body / crypto ----------
def sha256_hex(data: Union[str, bytes, None]) -> str:
if data is None:
data = b""
if isinstance(data, str):
data = data.encode("utf-8")
return hashlib.sha256(data).hexdigest()
# ---------- Signature ----------
def build_string_to_sign(req: dict) -> str:
method = req.get("method")
if not method:
raise SignError("sign: method is empty")
client_id = req.get("clientId")
if client_id is None or client_id == "":
raise SignError("sign: client id is empty")
timestamp = req.get("timestamp")
if timestamp is None or timestamp == "":
raise SignError("sign: timestamp is empty")
nonce = req.get("nonce")
if not nonce:
raise SignError("sign: nonce is empty")
return "\n".join([
str(method).upper(),
normalize_path(req.get("path")),
canonicalize_query(req.get("rawQuery") or ""),
# raw body — никакой нормализации; сервер хэширует полученные байты как есть
sha256_hex(req.get("body")),
str(client_id),
str(timestamp),
str(nonce),
])
def sign(req: dict, secret: Union[str, bytes]) -> str:
sts = build_string_to_sign(req)
key = secret.encode("utf-8") if isinstance(secret, str) else secret
return hmac.new(key, sts.encode("utf-8"), hashlib.sha256).hexdigest()
python3 -m unittest -v. Reference-векторы идентичны эталонам на других языках (секрет krionyx_secret_key).
import unittest
from signature import (
SignError, sign, canonicalize_query, normalize_path, sha256_hex,
percent_encode, percent_decode,
)
TEST_SECRET = "krionyx_secret_key"
class TestNormalizePath(unittest.TestCase):
def test_valid(self):
for raw, want in [
("", "/"), (None, "/"), ("/", "/"), ("//////", "/"),
("/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"),
]:
with self.subTest(raw=raw):
self.assertEqual(normalize_path(raw), want)
def test_invalid(self):
for raw in ("api/v1", "/api/./status", "/api/../x", "/.", "/..",
"/api.json", "/api%2Ftest", "/апи", "/api with space", "/api:8080"):
with self.subTest(raw=raw):
with self.assertRaises(SignError):
normalize_path(raw)
class TestCanonicalizeQuery(unittest.TestCase):
def test_valid(self):
for raw, want in [
("", ""), ("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"),
]:
with self.subTest(raw=raw):
self.assertEqual(canonicalize_query(raw), want)
def test_invalid(self):
for raw in ("=value", "a=1&&b=2", "&a=1", "a=1&", "q=%ZZ", "a=%",
"a=%C0", "a=%C0%80"):
with self.subTest(raw=raw):
with self.assertRaises(SignError):
canonicalize_query(raw)
class TestHelpers(unittest.TestCase):
def test_percent_encode(self):
self.assertEqual(percent_encode("hello world"), "hello%20world")
self.assertEqual(percent_encode("/test"), "%2Ftest")
def test_percent_decode(self):
self.assertEqual(percent_decode("a+b"), "a b")
self.assertEqual(percent_decode("a%2Fb"), "a/b")
def test_sha256_empty(self):
self.assertEqual(
sha256_hex(""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
REF_VECTORS = [
("simple_get_with_query", {"method": "GET", "path": "/api/billing/payment-methods", "rawQuery": "amount=20&hash=abc&paymentType=deposit", "body": "", "clientId": "merchant_1", "timestamp": "1730390400", "nonce": "550e8400-e29b-41d4-a716-446655440000"}, "b91ccfa5a51b7438b426c4db1326f56869fe90dfd7070b0bc933e86fdee62902"),
("post_json_body", {"method": "POST", "path": "/api/accounts/external-app/deposit", "body": '{"amount":2004,"currency":"RUB"}', "clientId": "merchant_1", "timestamp": "1730390400", "nonce": "b2c3d4e5-f6a7-8901-bcde-f12345678901"}, "45ddf408562b8f0d5a549cb19dc092d88d7ea55969f28c05d113c859803ea921"),
("lowercase_method_uppercased", {"method": "post", "path": "/api/x", "clientId": "m", "timestamp": "0", "nonce": "n"}, "d174dcaeed0a08b0d3297039c7863ce190cc50b0827ec95fcba21923cc2b8169"),
("unicode_body", {"method": "POST", "path": "/api/text", "body": '{"text":"Привет мир!"}', "clientId": "merch", "timestamp": "1700000000", "nonce": "nonce-1"}, "011366b50761f86384b65b78a00539915b5f06f701dd18c5e0f689d9a19409da"),
("html_chars_in_body", {"method": "POST", "path": "/api/text", "body": '{"html":"<div>&my test</div>"}', "clientId": "merch", "timestamp": "1700000000", "nonce": "nonce-2"}, "7e2800e716c1cf75b166d19bf41f3d287a1858da5c2c26a84fe7963d84827029"),
("messy_path_and_query", {"method": "GET", "path": "///api//withdrawal///status/", "rawQuery": "b=2&a=hello+world", "clientId": "m", "timestamp": "1700000000", "nonce": "n"}, "a33f50abeb513a6ba7b5989ca6ed034b6792a3c91e69db14a03c0cb21d863cfa"),
("repeated_query_params", {"method": "GET", "path": "/list", "rawQuery": "tag=b&tag=a&empty=&flag", "clientId": "m", "timestamp": "1700000000", "nonce": "n"}, "0661370163a7911271d58d428df900dac16d9207c4a1a9aa5c3d055165f5b0d6"),
("put_with_body", {"method": "PUT", "path": "/api/orders/123", "body": '{"status":"approved","amount":100.50}', "clientId": "m", "timestamp": "1700000000", "nonce": "n"}, "b267f362214607515fc6f62238bfe7f7f5e888196077b9cb22f35102e685fc4c"),
("delete_with_query", {"method": "DELETE", "path": "/api/orders/123", "rawQuery": "reason=duplicate&forced=true", "clientId": "m", "timestamp": "1700000000", "nonce": "n"}, "bd4bd8e778441c0a93c6fb0be354a3f8a0d76bb46e34b6abeb4ec700700c6d4d"),
("empty_body_root_path", {"method": "GET", "path": "/", "clientId": "cl", "timestamp": "1", "nonce": "u"}, "a628409be660a17ddc18e3aa79111a7a1b6c46ab8cc37734b7211b8c9326bfb7"),
("mixed_unicode_html_body", {"method": "POST", "path": "/api/payment", "rawQuery": "v=1", "body": '{"text":"тест & проверка 1 <ok>","amount":100.50}', "clientId": "merchant_xyz", "timestamp": "1730390400", "nonce": "nn"}, "07714c6d4bb71fe162bbba1f616d7e9f73effc148b74ffaff07604c7e8bbe7a5"),
("numeric_ids", {"method": "POST", "path": "/api/v2/external-app/withdraw", "body": '{"id":12345,"amount":1000}', "clientId": "42", "timestamp": "1730390400", "nonce": "abc-123"}, "af8fe442f768927920b1502800282de5ec6db60dbc4903dce45214b8ae7f04da"),
# тело с whitespace хэшируется СЫРЫМ (без компактизации)
("non_compact_body", {"method": "POST", "path": "/api/x", "body": '{ "x": 1 }', "clientId": "m", "timestamp": "1700000000", "nonce": "n"}, "8b5e71fc75d6b83a6b316a973ba881f3d7044c5eb55153622e66672b851f7b35"),
]
class TestSignReferenceVectors(unittest.TestCase):
def test_vectors(self):
for name, req, want in REF_VECTORS:
with self.subTest(name=name):
self.assertEqual(sign(req, TEST_SECRET), want)
if __name__ == "__main__":
unittest.main(verbosity=2)