Реализация на чистом ядре PHP (hash, hash_hmac, rawurlencode, PCRE), PHP 7.4+. Алгоритм и спецификация — Подпись запросов. Подпись побайтно совпадает с Go, JS и Python.
require 'SignatureV2.php';
$url = 'https://api.idynsys.org/api/billing/payment-methods';
$body = '{"amount":20,"currency":"RUB"}'; // ровно те байты, что уйдут в запрос
$secret = 'app_secret_key';
// path + rawQuery из URL (split по первому "?"; normalizePath примет и полный URL)
$parts = explode('?', $url, 2);
$rawQuery = $parts[1] ?? '';
$req = [
'method' => 'POST',
'path' => $parts[0],
'rawQuery' => $rawQuery,
'body' => $body,
'clientId' => 'merchant_1',
'timestamp' => (string) time(),
'nonce' => bin2hex(random_bytes(16)), // или UUID v4
];
$headers = [
SignatureV2::HEADER_CLIENT_ID => $req['clientId'],
SignatureV2::HEADER_TIMESTAMP => $req['timestamp'],
SignatureV2::HEADER_NONCE => $req['nonce'],
SignatureV2::HEADER_SIGNATURE => SignatureV2::sign($req, $secret),
];
// отправляете $body и $headers тем же HTTP-клиентом (curl/guzzle)
body хэшируется в сыром виде — sha256 от тех же байт, что уйдут в запрос, без какой-либо нормализации. Передавайте в sign и в реальный запрос одну и ту же строку.
⚠️ В PHP сортировка query сделана через
strcmp(байтовое сравнение): обычный<для строк сравнивает числовые строки численно ("10" < "2"→ true) и сломал бы порядок.percentEncode=rawurlencode(RFC 3986).
<?php
declare(strict_types=1);
/**
* Реализация схемы подписи внешних запросов v2.
*
* 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))
*
* Зависит только от ядра PHP. PHP 7.4+. Класс без namespace — для copy-paste.
*/
final class SignException extends \RuntimeException
{
}
final class SignatureV2
{
public const HEADER_SIGNATURE = 'X-Signature';
public const HEADER_TIMESTAMP = 'X-Timestamp';
public const HEADER_NONCE = 'X-Nonce';
public const HEADER_CLIENT_ID = 'X-Client-Id';
/** @param array<string,mixed> $req */
public static function sign(array $req, string $secret): string
{
return hash_hmac('sha256', self::buildStringToSign($req), $secret);
}
public static function buildStringToSign(array $req): string
{
$method = (string)($req['method'] ?? '');
if ($method === '') {
throw new SignException('sign: method is empty');
}
$clientId = $req['clientId'] ?? '';
if ($clientId === '' || $clientId === null) {
throw new SignException('sign: client id is empty');
}
$timestamp = $req['timestamp'] ?? '';
if ($timestamp === '' || $timestamp === null) {
throw new SignException('sign: timestamp is empty');
}
$nonce = $req['nonce'] ?? '';
if ($nonce === '' || $nonce === null) {
throw new SignException('sign: nonce is empty');
}
return implode("\n", [
strtoupper($method),
self::normalizePath($req['path'] ?? null),
self::canonicalizeQuery($req['rawQuery'] ?? null),
// body хэшируется в СЫРОМ виде — ровно те байты, что уйдут в запрос.
self::sha256Hex((string)($req['body'] ?? '')),
(string)$clientId,
(string)$timestamp,
(string)$nonce,
]);
}
public static function sha256Hex(string $data): string
{
return hash('sha256', $data);
}
// ---------- PATH ----------
public static function normalizePath(?string $rawPath): string
{
if ($rawPath === null || $rawPath === '') {
return '/';
}
$p = $rawPath;
$sch = strpos($p, '://');
if ($sch !== false) {
$rest = substr($p, $sch + 3);
$slash = strpos($rest, '/');
if ($slash !== false) {
$p = substr($rest, $slash);
} else {
return '/';
}
}
$qi = strpos($p, '?');
if ($qi !== false) {
$p = substr($p, 0, $qi);
}
$fi = strpos($p, '#');
if ($fi !== false) {
$p = substr($p, 0, $fi);
}
if ($p === '') {
return '/';
}
if ($p[0] !== '/') {
throw new SignException('sign: path must start with /');
}
foreach (explode('/', $p) as $seg) {
if ($seg === '.' || $seg === '..') {
throw new SignException('sign: path contains dotted segment: ' . $seg);
}
}
$len = strlen($p);
for ($i = 0; $i < $len; $i++) {
if (!self::isAllowedPathByte(ord($p[$i]))) {
throw new SignException('sign: path contains invalid character at index ' . $i);
}
}
while (strpos($p, '//') !== false) {
$p = str_replace('//', '/', $p);
}
if (strlen($p) > 1 && substr($p, -1) === '/') {
$p = rtrim($p, '/');
}
return $p === '' ? '/' : $p;
}
private static function isAllowedPathByte(int $c): bool
{
return ($c >= 0x41 && $c <= 0x5A)
|| ($c >= 0x61 && $c <= 0x7A)
|| ($c >= 0x30 && $c <= 0x39)
|| $c === 0x2F || $c === 0x2D || $c === 0x5F;
}
// ---------- QUERY ----------
public static function canonicalizeQuery(?string $rawQuery): string
{
if ($rawQuery === null || $rawQuery === '') {
return '';
}
$q = $rawQuery;
if ($q[0] === '?') {
$q = substr($q, 1);
}
$hi = strpos($q, '#');
if ($hi !== false) {
$q = substr($q, 0, $hi);
}
if ($q === '') {
return '';
}
$pairs = [];
foreach (explode('&', $q) as $part) {
if ($part === '') {
throw new SignException("sign: query has empty fragment between '&'");
}
$eq = strpos($part, '=');
if ($eq !== false) {
$rawKey = substr($part, 0, $eq);
$rawVal = substr($part, $eq + 1);
} else {
$rawKey = $part;
$rawVal = '';
}
if ($rawKey === '') {
throw new SignException('sign: query parameter has empty key');
}
$pairs[] = [
self::percentEncode(self::percentDecode($rawKey)),
self::percentEncode(self::percentDecode($rawVal)),
];
}
// ВАЖНО: strcmp (байтовое), а не "<": PHP сравнивает числовые строки
// численно ("10" < "2" => true), что сломало бы порядок параметров.
usort($pairs, static function (array $a, array $b): int {
$k = strcmp($a[0], $b[0]);
return $k !== 0 ? $k : strcmp($a[1], $b[1]);
});
$out = [];
foreach ($pairs as $p) {
$out[] = $p[0] . '=' . $p[1];
}
return implode('&', $out);
}
public static function percentEncode(string $s): string
{
return rawurlencode($s);
}
public static function percentDecode(string $s): string
{
$out = '';
$n = strlen($s);
for ($i = 0; $i < $n; $i++) {
$c = $s[$i];
if ($c === '%') {
if ($i + 2 >= $n) {
throw new SignException('sign: invalid percent-encoding in query');
}
$hi = self::hexVal($s[$i + 1]);
$lo = self::hexVal($s[$i + 2]);
if ($hi < 0 || $lo < 0) {
throw new SignException('sign: invalid percent-encoding in query');
}
$out .= chr(($hi << 4) | $lo);
$i += 2;
} elseif ($c === '+') {
$out .= ' ';
} else {
$out .= $c;
}
}
if (preg_match('//u', $out) !== 1) {
throw new SignException('sign: query is not valid UTF-8 after percent-decoding');
}
return $out;
}
private static function hexVal(string $c): int
{
$o = ord($c);
if ($o >= 0x30 && $o <= 0x39) {
return $o - 0x30;
}
if ($o >= 0x41 && $o <= 0x46) {
return $o - 0x41 + 10;
}
if ($o >= 0x61 && $o <= 0x66) {
return $o - 0x61 + 10;
}
return -1;
}
}
php test_signature_v2.php. Reference-векторы идентичны эталонам на других языках (секрет krionyx_secret_key). Полный набор — в репозитории ids-libraries/pkg/sign/v2/php/test_signature_v2.php.
<?php
declare(strict_types=1);
require 'SignatureV2.php';
const TEST_SECRET = 'krionyx_secret_key';
$passed = 0;
$failed = 0;
function eq(string $name, $want, $got): void
{
global $passed, $failed;
if ($want === $got) {
$passed++;
} else {
$failed++;
fwrite(STDERR, "FAIL {$name}: want=" . var_export($want, true) . " got=" . var_export($got, true) . "\n");
}
}
function throws(string $name, callable $fn): void
{
global $passed, $failed;
try {
$fn();
$failed++;
fwrite(STDERR, "FAIL {$name}: expected throw\n");
} catch (SignException $e) {
$passed++;
}
}
// normalizePath
foreach ([
'' => '/', '/' => '/', '//////' => '/', '/api/' => '/api',
'///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',
] as $in => $want) {
eq("normalizePath {$in}", $want, SignatureV2::normalizePath((string)$in));
}
foreach (['api/v1', '/api/./status', '/api/../x', '/.', '/api.json', '/api%2Ftest', '/апи', '/api with space', '/api:8080'] as $in) {
throws("normalizePath reject {$in}", static fn() => SignatureV2::normalizePath($in));
}
// canonicalizeQuery
foreach ([
'' => '', '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',
] as $in => $want) {
eq("canonicalizeQuery {$in}", $want, SignatureV2::canonicalizeQuery((string)$in));
}
foreach (['=value', 'a=1&&b=2', '&a=1', 'a=1&', 'q=%ZZ', 'a=%', 'a=%C0', 'a=%C0%80'] as $in) {
throws("canonicalizeQuery reject {$in}", static fn() => SignatureV2::canonicalizeQuery($in));
}
eq('sha256 empty', 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', SignatureV2::sha256Hex(''));
eq('percentEncode space', 'hello%20world', SignatureV2::percentEncode('hello world'));
// reference vectors (golden)
$refVectors = [
['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'],
];
foreach ($refVectors as [$name, $req, $want]) {
eq("sign ref: {$name}", $want, SignatureV2::sign($req, TEST_SECRET));
}
echo "\n{$passed}/" . ($passed + $failed) . " passed, {$failed} failed\n";
exit($failed > 0 ? 1 : 0);