Подпись HTML-формы
Любое поле формы покупатель может изменить прямо в браузере — например, поставить сумму меньше. Поэтому поля счета передаются в подписанном контейнере: сервис считает подпись заново и, если она не совпала, счет не создает.
Подпись считает сервер магазина. В браузер уходит только готовая пара data и signature.
Что подписывается
data = base64url(json)
signature = HMAC-SHA256(data, formSecret)
HMAC считается от строки data, а не от байтов JSON. Приемник сверяет подпись с присланной строкой как она есть и только потом раскодирует ее.
Отсюда следует, что канонизации нет: порядок ключей, отступы в JSON и способ экранирования юникода на подпись не влияют. Подходит любой штатный JSON-кодировщик.
Эталонный набор
Подставьте эти значения в свой код и сверьте результат.
Секрет для примера:
dvc3khto5WpjEPojZfYJsbJzFEPPVnRa
Сумма и срок оплаты числами, строкой они тоже принимаются:
{"projectId":"a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d","amountFiat":10.5,"currencyFiat":"USD","timeToPay":1,"description":"Order #7, delivery","serviceData":"order-7"}
Строка data — base64url этих байтов. Паддинг здесь отброшен, приемник принимает и с ним:
eyJwcm9qZWN0SWQiOiJhMWIyYzNkNC01ZTZmLTRhN2ItOGM5ZC0wZTFmMmEzYjRjNWQiLCJhbW91bnRGaWF0IjoxMC41LCJjdXJyZW5jeUZpYXQiOiJVU0QiLCJ0aW1lVG9QYXkiOjEsImRlc2NyaXB0aW9uIjoiT3JkZXIgIzcsIGRlbGl2ZXJ5Iiwic2VydmljZURhdGEiOiJvcmRlci03In0
Подпись этой строки на секрете выше:
09628020c47400c9231420450af642adfdb3c246f33e3e12cb8e28f96c2a785e
Примеры ниже с этими значениями печатают ровно такие data и подпись. Тот же JSON с ключом "output":"errors" в конце дает другую строку data и другую подпись.
Если data не совпала, а JSON выглядит так же — дело в кодировщике JSON: другой порядок ключей или пробелы после разделителей. Приемник примет и такой контейнер, но с эталоном он дословно не сойдется.
Примеры
- PHP
- TypeScript
- JavaScript
- Python
<?php
function signedContainer(array $fields, string $formSecret): array {
$data = rtrim(strtr(base64_encode(json_encode($fields)), '+/', '-_'), '=');
return [
'data' => $data,
'signature' => hash_hmac('sha256', $data, $formSecret),
];
}
// Test values from the reference set. Before going live,
// replace the secret with the form secret from your project settings
$container = signedContainer([
'projectId' => 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
'amountFiat' => 10.5,
'currencyFiat' => 'USD',
'timeToPay' => 1,
'description' => 'Order #7, delivery',
'serviceData' => 'order-7',
], 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');
echo $container['data'], "\n";
echo $container['signature'], "\n";
import { createHmac } from 'node:crypto';
// Values are JSON strings or numbers, optional keys may be omitted
export interface FormFields {
projectId: string;
amountFiat: string | number;
currencyFiat: string;
timeToPay: string | number;
description?: string;
serviceData?: string;
output?: string;
}
export interface FormContainer {
data: string;
signature: string;
}
export const signedContainer = (fields: FormFields, formSecret: string): FormContainer => {
const data = Buffer.from(JSON.stringify(fields), 'utf8').toString('base64url');
return {
data,
signature: createHmac('sha256', formSecret).update(data, 'utf8').digest('hex'),
};
};
// Test values from the reference set. Before going live,
// replace the secret with the form secret from your project settings
const container = signedContainer({
projectId: 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
amountFiat: 10.5,
currencyFiat: 'USD',
timeToPay: 1,
description: 'Order #7, delivery',
serviceData: 'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');
console.log(container.data);
console.log(container.signature);
Это код для сервера магазина, не для браузера: подпись считается там, где хранится секрет.
import { createHmac } from 'node:crypto';
const signedContainer = (fields, formSecret) => {
const data = Buffer.from(JSON.stringify(fields), 'utf8').toString('base64url');
return {
data,
signature: createHmac('sha256', formSecret).update(data, 'utf8').digest('hex'),
};
};
// Test values from the reference set. Before going live,
// replace the secret with the form secret from your project settings
const container = signedContainer({
projectId: 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
amountFiat: 10.5,
currencyFiat: 'USD',
timeToPay: 1,
description: 'Order #7, delivery',
serviceData: 'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa');
console.log(container.data);
console.log(container.signature);
import base64
import hashlib
import hmac
import json
def signed_container(fields: dict, form_secret: str) -> dict:
# compact separators: the default ones add spaces after ',' and ':'
body = json.dumps(fields, separators=(',', ':'))
data = base64.urlsafe_b64encode(body.encode('utf-8')).decode().rstrip('=')
return {
'data': data,
'signature': hmac.new(
form_secret.encode('utf-8'), data.encode('utf-8'), hashlib.sha256
).hexdigest(),
}
# Test values from the reference set. Before going live,
# replace the secret with the form secret from your project settings
container = signed_container({
'projectId': 'a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d',
'amountFiat': 10.5,
'currencyFiat': 'USD',
'timeToPay': 1,
'description': 'Order #7, delivery',
'serviceData': 'order-7',
}, 'dvc3khto5WpjEPojZfYJsbJzFEPPVnRa')
print(container['data'])
print(container['signature'])
Аргумент separators нужен для дословного совпадения с эталоном. Приемнику подойдет и JSON с настройками по умолчанию.
Значения в примерах латиницей, но код работает с любым юникодом без изменений. PHP и Python по умолчанию экранируют не-ASCII в \uXXXX, JavaScript отправляет сырой UTF-8 — приемник принимает оба вида.
Две частые ошибки
Подписан JSON вместо контейнера. HMAC считается от строки data, то есть от base64url. Ошибка выглядит как «все совпадает, а подпись нет».
Контейнер пересобран после подписи. Изменился хоть байт в data — подпись недействительна. Собирайте пару в одном месте кода.
Если подпись не сходится
Идите по цепочке JSON → data → подпись, где первое звено разошлось с эталоном — там и причина. Сначала сверьте data: она не зависит от секрета.