Реализация для Node.js (только встроенный crypto, без npm-зависимостей). Алгоритм и спецификация — Подпись запросов. Подпись побайтно совпадает с Go, Python и PHP. Для Postman — см. Подпись — Postman.
const crypto = require('crypto');
const { sign, HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_CLIENT_ID } =
require('./signature'); // код из спойлера ниже
const url = 'https://api.idynsys.org/api/billing/payment-methods';
const body = JSON.stringify({ amount: 20, currency: 'RUB' }); // те же байты, что уйдут в запрос
const secret = process.env.MERCHANT_SECRET;
// path + rawQuery из URL (split по первому "?"; normalizePath примет и полный URL)
let path = url, rawQuery = '';
const qi = path.indexOf('?');
if (qi >= 0) { rawQuery = path.substring(qi + 1); path = path.substring(0, qi); }
const req = {
method: 'POST',
path,
rawQuery,
body,
clientId: 'merchant_1',
timestamp: String(Math.floor(Date.now() / 1000)),
nonce: crypto.randomUUID(),
};
const headers = {
[HEADER_CLIENT_ID]: req.clientId,
[HEADER_TIMESTAMP]: req.timestamp,
[HEADER_NONCE]: req.nonce,
[HEADER_SIGNATURE]: sign(req, secret),
};
await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body });
body хэшируется в сыром виде — sha256 от тех же байт, что уйдут в запрос, без какой-либо нормализации. Передавайте в sign и в реальный запрос одну и ту же строку.
'use strict';
/**
* Реализация схемы подписи внешних запросов.
*
* 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))
*/
const crypto = require('crypto');
// ---------- Path normalization ----------
function isAllowedPathByte(c) {
return (
(c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A) ||
(c >= 0x30 && c <= 0x39) || c === 0x2F || c === 0x2D || c === 0x5F
);
}
function normalizePath(rawPath) {
if (rawPath === undefined || rawPath === null || rawPath === '') return '/';
let p = String(rawPath);
const sch = p.indexOf('://');
if (sch >= 0) {
const rest = p.substring(sch + 3);
const slash = rest.indexOf('/');
if (slash >= 0) p = rest.substring(slash);
else return '/';
}
const qi = p.indexOf('?'); if (qi >= 0) p = p.substring(0, qi);
const fi = p.indexOf('#'); if (fi >= 0) p = p.substring(0, fi);
if (p === '') return '/';
if (!p.startsWith('/')) throw new Error('sign: path must start with /');
for (const seg of p.split('/')) {
if (seg === '.' || seg === '..') {
throw new Error(`sign: path contains dotted segment: ${JSON.stringify(seg)}`);
}
}
for (let i = 0; i < p.length; i++) {
if (!isAllowedPathByte(p.charCodeAt(i))) {
throw new Error(`sign: path contains invalid character: ${JSON.stringify(p[i])} at index ${i}`);
}
}
while (p.includes('//')) p = p.replace(/\/\//g, '/');
if (p.length > 1 && p.endsWith('/')) p = p.replace(/\/+$/, '');
if (p === '') return '/';
return p;
}
// ---------- Query canonicalization ----------
function isUnreservedByte(b) {
return (
(b >= 0x41 && b <= 0x5A) || (b >= 0x61 && b <= 0x7A) ||
(b >= 0x30 && b <= 0x39) ||
b === 0x2D || b === 0x2E || b === 0x5F || b === 0x7E
);
}
function percentEncode(str) {
const buf = Buffer.from(str, 'utf8');
let out = '';
for (const byte of buf) {
if (isUnreservedByte(byte)) out += String.fromCharCode(byte);
else out += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
}
return out;
}
function unhexNibble(c) {
if (c >= 0x30 && c <= 0x39) return c - 0x30;
if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10;
if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10;
return -1;
}
function percentDecode(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i);
if (c === 0x25) {
if (i + 2 >= str.length) throw new Error('sign: invalid percent-encoding in query');
const hi = unhexNibble(str.charCodeAt(i + 1));
const lo = unhexNibble(str.charCodeAt(i + 2));
if (hi < 0 || lo < 0) throw new Error('sign: invalid percent-encoding in query');
bytes.push((hi << 4) | lo);
i += 2;
} else if (c === 0x2B) {
bytes.push(0x20);
} else if (c > 0x7F) {
for (const b of Buffer.from(str[i], 'utf8')) bytes.push(b);
} else {
bytes.push(c);
}
}
const buf = Buffer.from(bytes);
const decoded = buf.toString('utf8');
if (Buffer.from(decoded, 'utf8').compare(buf) !== 0) {
throw new Error('sign: query is not valid UTF-8 after percent-decoding');
}
return decoded;
}
function canonicalizeQuery(rawQuery) {
if (!rawQuery) return '';
let q = String(rawQuery);
if (q.startsWith('?')) q = q.substring(1);
const hi = q.indexOf('#');
if (hi >= 0) q = q.substring(0, hi);
if (q === '') return '';
const pairs = [];
for (const part of q.split('&')) {
if (part === '') throw new Error("sign: query has empty fragment between '&'");
const eq = part.indexOf('=');
const rawKey = eq >= 0 ? part.substring(0, eq) : part;
const rawVal = eq >= 0 ? part.substring(eq + 1) : '';
if (rawKey === '') throw new Error('sign: query parameter has empty key');
pairs.push({
encKey: percentEncode(percentDecode(rawKey)),
encVal: percentEncode(percentDecode(rawVal)),
});
}
pairs.sort((a, b) => {
if (a.encKey < b.encKey) return -1;
if (a.encKey > b.encKey) return 1;
if (a.encVal < b.encVal) return -1;
if (a.encVal > b.encVal) return 1;
return 0;
});
return pairs.map(p => `${p.encKey}=${p.encVal}`).join('&');
}
// ---------- Body / crypto ----------
function sha256Hex(data) {
if (data === undefined || data === null) data = Buffer.alloc(0);
if (typeof data === 'string') data = Buffer.from(data, 'utf8');
return crypto.createHash('sha256').update(data).digest('hex');
}
// ---------- Signature ----------
function buildStringToSign(req) {
if (!req || !req.method) throw new Error('sign: method is empty');
if (req.clientId === undefined || req.clientId === null || req.clientId === '') {
throw new Error('sign: client id is empty');
}
if (req.timestamp === undefined || req.timestamp === null || req.timestamp === '') {
throw new Error('sign: timestamp is empty');
}
if (!req.nonce) throw new Error('sign: nonce is empty');
return [
String(req.method).toUpperCase(),
normalizePath(req.path),
canonicalizeQuery(req.rawQuery || ''),
// raw body — никакой нормализации; сервер хэширует полученные байты как есть
sha256Hex(req.body || ''),
String(req.clientId),
String(req.timestamp),
String(req.nonce),
].join('\n');
}
function sign(req, secret) {
const sts = buildStringToSign(req);
const key = typeof secret === 'string' ? Buffer.from(secret, 'utf8') : secret;
return crypto.createHmac('sha256', key).update(sts, 'utf8').digest('hex');
}
const HEADER_SIGNATURE = 'X-Signature';
const HEADER_TIMESTAMP = 'X-Timestamp';
const HEADER_NONCE = 'X-Nonce';
const HEADER_CLIENT_ID = 'X-Client-Id';
module.exports = {
sign, buildStringToSign, canonicalizeQuery, normalizePath, sha256Hex,
percentEncode, percentDecode,
HEADER_SIGNATURE, HEADER_TIMESTAMP, HEADER_NONCE, HEADER_CLIENT_ID,
};
node signature.test.js. Reference-векторы идентичны эталонам на других языках (секрет krionyx_secret_key).
'use strict';
const assert = require('assert');
const {
sign, canonicalizeQuery, normalizePath, sha256Hex, percentEncode, percentDecode,
} = require('./signature');
const TEST_SECRET = 'krionyx_secret_key';
const results = [];
function test(name, fn) { try { fn(); results.push({ name, ok: true }); } catch (e) { results.push({ name, ok: false, err: e }); } }
function throws(fn, re) { let t = false; try { fn(); } catch (e) { t = true; if (re && !re.test(e.message)) throw new Error(`bad msg: ${e.message}`); } if (!t) throw new Error('expected throw'); }
// ---- normalizePath ----
for (const [i, o] of [['', '/'], ['/', '/'], ['//////', '/'], ['/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']]) {
test(`normalizePath ${i}`, () => assert.strictEqual(normalizePath(i), o));
}
for (const bad of ['api/v1', '/api/./status', '/api/../x', '/.', '/api.json', '/api%2Ftest', '/апи', '/api with space', '/api:8080']) {
test(`normalizePath reject ${bad}`, () => throws(() => normalizePath(bad)));
}
// ---- canonicalizeQuery ----
for (const [i, o] of [['', ''], ['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']]) {
test(`canonicalizeQuery ${i}`, () => assert.strictEqual(canonicalizeQuery(i), o));
}
for (const bad of ['=value', 'a=1&&b=2', '&a=1', 'a=1&', 'q=%ZZ', 'a=%', 'a=%C0', 'a=%C0%80']) {
test(`canonicalizeQuery reject ${bad}`, () => throws(() => canonicalizeQuery(bad)));
}
// ---- percentEncode / sha256 ----
test('percentEncode space', () => assert.strictEqual(percentEncode('hello world'), 'hello%20world'));
test('percentEncode slash', () => assert.strictEqual(percentEncode('/test'), '%2Ftest'));
test('percentDecode plus', () => assert.strictEqual(percentDecode('a+b'), 'a b'));
test('sha256 empty', () => assert.strictEqual(sha256Hex(''), 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'));
// ---- reference vectors (golden) ----
const REF = [
['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'],
];
for (const [name, req, want] of REF) {
test(`sign ref: ${name}`, () => assert.strictEqual(sign(req, TEST_SECRET), want, `vector ${name} mismatch`));
}
const passed = results.filter(r => r.ok).length;
for (const r of results.filter(r => !r.ok)) console.error(`FAIL ${r.name}: ${r.err && r.err.message}`);
console.log(`\n${passed}/${results.length} passed`);
if (passed !== results.length) process.exit(1);
Готовый Pre-request Script и инструкция по установке — на отдельной странице Подпись — Postman.