Проверка подписи
Результаты серверных запросов (методы getMe, getPhone и getContacts) содержат специальное поле sign.
С помощью этого поля серверная сторона вашего приложения может убедиться, что клиент отправил данные, действительно полученные со стороны Aitu Bridge.
В конце статьи предоставлены референсные имплементации для проверки подписи на некоторых серверных языках, которые можно скопировать и использовать на вашем сервере.
Если у ответа нет поля sign, то возможность проверить подлинность отсутствует. В таком случае относитесь к клиентским данным с опаской.
Подлинность данных гарантируется тем, что API-ключ для подписывания данных хранится только в Aitu и на стороне вашего сервера.
Не передавайте API-ключ третьим лицам!
В случае подозрения на компрометацию API-ключа, следует как можно скорее сформировать новый в личном кабинете. Предыдущий при этом деактивируется.
Алгоритм проверки достоверности данных
Для того, чтобы убедиться в достоверности полученных данных, необходимо следовать следующему алгоритму:
Получить результат ответа API-метода — некий JSON-объект.
Исключить из этого результата поле sign, после чего у него (и у всех вложенных объектов) исключить ключи с false-значениями (
0
,null
,false
, пустой строкой""
), пустыми массивами[]
или пустыми объектами{}
. Затем преобразовать его в строку видаключ1:значение1ключ2:значение2ключ3:значение3
, где все ключи объектов отсортированы по алфавиту.Используя алгоритм проверки целостности HMAC с хеш-функцией sha256 и API-ключом, получить хеш строки, сформированной на предыдущем шаге.
Полученный хеш закодировать в base64-строку (base64url-вариант, с заменой
+
и/
на-
и_
соответственно, с сохранением возможных=
в конце).Сравнить результат предыдущего шага с полем sign пришедшего объекта.
При формировании строки для хеширования следует исключить из нее ключ sign.
Ключи вложенных объектов, а также объектов находящихся в списках, также должны сортироваться в алфавитном порядке и добавляться к строке последовательно согласно порядку сортировки их родительских ключей.
Ключи объектов со значениями 0
,null, false
или ""
(пустая строка), []
(пустой массив), {}
(пустой объект) опускаются.
В качестве хеш-функции для HMAC необходимо использовать sha256.
Если значение сформированного хеша в base64url совпадает с полем sign полученного объекта, то данные достоверны.
Пример
Пусть API-ключ имеет значение my_secret_key
. Предположим, при вызове метода getContacts был получен следующий JSON-объект (для демонстрации в нем намеренно перемешаны ключи и добавлены ключи с опускаемыми значениями):
{
"empty_string_key": "",
"sign": "tdMk-vw3bTMPDMldnx4MgCbdJJNH2B60LizMzHv_De4=",
"contacts": [
{
"last_name": "pupkin",
"phone": "7991118837",
"first_name": "vasya",
"null_key_deep": null
},
{
"first_name": "john",
"last_name": "doe",
"phone": "79992222210"
},
{
"first_name": "kavychka",
"last_name": "\"",
"phone": "79992222211"
}
],
"zero_key": 0,
"null_key": null,
"false_key": false,
"empty_array": [],
"empty_object": {}
}
Строка для хэширования, сформированная по описанному выше алгоритму, должна получиться следующей:
contacts:first_name:vasyalast_name:pupkinphone:7991118837first_name:johnlast_name:doephone:79992222210first_name:kavychkalast_name:"phone:79992222211
Обратите внимание, что между парами нет разделителей.
Результат формирования HMAC-хеша (на базе алгоритма sha256) этой строки с ключом my_secret_key
с кодированием в base64url должен получиться следующим:
tdMk-vw3bTMPDMldnx4MgCbdJJNH2B60LizMzHv_De4=
Для тестирования генерации HMAC можно воспользоваться online-сервисами. Например, https://www.devglan.com/online-tools/hmac-sha256-online с выводом результата в base64-формате. Обратите внимание, что полученный в этом сервисе хеш нужно дополнительно привести в base64url-формат, заменив все +
на -
, а /
на _
.
Полученный хеш совпадает с полем sign исходного объекта, а значит данные подлинные.
Референсные имплементации
function isValidSign(fullBridgeResponse, secretKey) {
if (
typeof fullBridgeResponse !== 'string' && typeof fullBridgeResponse !== 'object'
|| typeof secretKey !== 'string'
) {
throw new Error('Can accept string or object as first argument and string as second argument only');
}
const { sign: inputSign, ...bridgeResponse } = typeof fullBridgeResponse === 'object'
? fullBridgeResponse
: JSON.parse(fullBridgeResponse);
const hmac = require('crypto').createHmac('sha256', secretKey);
const base64URLUnsafeHash = hmac.update(makeStringToHash(bridgeResponse)).digest('base64');
const calculatedSign = base64URLUnsafeHash.replace(/\+/g, '-').replace(/\//g, '_');
return inputSign === calculatedSign;
function makeStringToHash(input) {
if (Array.isArray(input)) {
return input.map(makeStringToHash).join('');
} else if (typeof input === 'object') {
return Object.keys(input)
.filter((key) => input[key] && (!Array.isArray(input[key]) || input[key].length > 0) && (typeof input[key] !== 'object' || Object.keys(input[key]).length > 0))
.sort()
.map((key) => `${key}:${makeStringToHash(input[key])}`)
.join('');
} else {
return String(input);
}
}
}
const bridgeResponseJSONExample = '{"empty_string_key":"","sign":"tdMk-vw3bTMPDMldnx4MgCbdJJNH2B60LizMzHv_De4=","contacts":[{"last_name":"pupkin","phone":"7991118837","first_name":"vasya","null_key_deep":null},{"first_name":"john","last_name":"doe","phone":"79992222210"},{"first_name":"kavychka","last_name":"\\"","phone":"79992222211"}],"zero_key":0,"null_key":null}';
const secretKeyExample = 'my_secret_key';
console.log(isValidSign(bridgeResponseJSONExample, secretKeyExample)); // Should be true
function isValidSign(fullBridgeResponse: Record<string, unknown> | string, secretKey: string) {
const { sign: inputSign, ...bridgeResponse } = typeof fullBridgeResponse === 'object'
? fullBridgeResponse
: JSON.parse(fullBridgeResponse);
const hmac = require('crypto').createHmac('sha256', secretKey);
const base64URLUnsafeHash: string = hmac.update(makeStringToHash(bridgeResponse)).digest('base64');
const calculatedSign = base64URLUnsafeHash.replace(/\+/g, '-').replace(/\//g, '_');
return inputSign === calculatedSign;
function makeStringToHash(input): string {
if (Array.isArray(input)) {
return input.map(makeStringToHash).join('');
} else if (typeof input === 'object') {
return Object.keys(input)
.filter((key) => input[key] && (!Array.isArray(input[key]) || input[key].length > 0) && (typeof input[key] !== 'object' || Object.keys(input[key]).length > 0))
.sort()
.map((key) => `${key}:${makeStringToHash(input[key])}`)
.join('');
} else {
return String(input);
}
}
}
const bridgeResponseJSONExample = '{"empty_string_key":"","sign":"tdMk-vw3bTMPDMldnx4MgCbdJJNH2B60LizMzHv_De4=","contacts":[{"last_name":"pupkin","phone":"7991118837","first_name":"vasya","null_key_deep":null},{"first_name":"john","last_name":"doe","phone":"79992222210"},{"first_name":"kavychka","last_name":"\\"","phone":"79992222211"}],"zero_key":0,"null_key":null}';
const secretKeyExample = 'my_secret_key';
console.log(isValidSign(bridgeResponseJSONExample, secretKeyExample)); // Should be true
require 'JSON'
require 'OpenSSL'
require 'base64'
def check_sign(data, secret_key)
sign = data.delete('sign')
string = build_string(data)
mac = OpenSSL::HMAC.digest('SHA256', secret_key, string)
base64_url = Base64.urlsafe_encode64 mac
sign == base64_url
end
def build_string(obj)
if obj.is_a? Hash
return obj.reject { |_k, v| v == 0 || v.nil? || v == '' || v == false || (v.is_a? Hash && v.empty?) || (v.is_a? Array && v.empty?) }.sort.to_h.map do |k, v|
k.to_s + ':' + build_string(v)
end.join
end
if obj.is_a? Array
return obj.map { |el| build_string(el) }.join
end
obj.to_s
end
json = <<JSON
{
"empty_string_key": "",
"sign": "tdMk-vw3bTMPDMldnx4MgCbdJJNH2B60LizMzHv_De4=",
"contacts": [
{
"last_name": "pupkin",
"phone": "7991118837",
"first_name": "vasya",
"null_key_deep": null
},
{
"first_name": "john",
"last_name": "doe",
"phone": "79992222210"
},
{
"first_name": "kavychka",
"last_name": "\\\"",
"phone": "79992222211"
}
],
"zero_key": 0,
"null_key": null
}
JSON
data = JSON.parse(json)
p check_sign(data, 'my_secret_key')
import base64
import hashlib
import hmac
def get_sign_data_from_dict(obj: dict) -> str:
result = ''
keys = sorted(obj.keys())
for key in keys:
val = obj[key]
if not val:
continue
if isinstance(val, dict):
result += key + ':' + get_sign_data_from_dict(val)
continue
if isinstance(val, list):
result += key + ':' + get_sign_data_from_list(val)
continue
result += key + ':' + str(val)
return result
def get_sign_data_from_list(data: list) -> str:
result = ''
for d in data:
result += get_sign_data_from_dict(d)
return result
data = {
"contacts": [
{"first_name": "FirstName", "last_name": "LastName", "phone": "PhoneNumber"},
{"first_name": "OnlyFirstName", "last_name": "", "phone": ""},
{"first_name": "", "last_name": "OnlyLastName", "phone": ""},
{"first_name": "", "last_name": "", "phone": "OnlyPhoneNumber"}
]
}
message = get_sign_data_from_dict(data)
assert message == 'contacts:first_name:FirstNamelast_name:LastNamephone:PhoneNumberfirst_name:OnlyFirstNamelast_name:OnlyLastNamephone:OnlyPhoneNumber'
message = bytes(message, 'utf-8')
secret = bytes('secret', 'utf-8')
signature = base64.urlsafe_b64encode(hmac.new(secret, message, digestmod=hashlib.sha256).digest())
assert signature.decode('utf8') == 'LNfD638IVfC5x-XVhKXWFE7ztRRATDbLgqNgiOvefuo='
data = {"contacts": []}
message = get_sign_data_from_dict(data)
assert message == ''
message = bytes(message, 'utf-8')
secret = bytes('secret', 'utf-8')
signature = base64.urlsafe_b64encode(hmac.new(secret, message, digestmod=hashlib.sha256).digest())
assert signature.decode('utf8') == '-eZuF5tnR65UEI-C-K3os8Jddv0wr95sOVgixTAZYWk='
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.lang.reflect.Array;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class SignValidator {
private final StringBuilder result = new StringBuilder();
private String calculatedSign;
private String sign;
public SignValidator(Object object, String secret) {
try {
ObjectMapper om = new ObjectMapper();
String jsonString = om.writeValueAsString(object);
Map<String, Object> map = om.readValue(jsonString, Map.class);
calcSign(map, secret);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
public SignValidator(Map<String, Object> jsonMap, String secret) {
calcSign(jsonMap, secret);
}
private void calcSign(Map<String, Object> jsonMap, String secret) {
fillSignStringForMap(jsonMap);
byte[] signature = calcHmacSha256(secret.getBytes(StandardCharsets.UTF_8),
result.toString().getBytes(StandardCharsets.UTF_8));
calculatedSign = Base64.getUrlEncoder().encodeToString(signature);
sign = getExpectedSign(jsonMap);
}
byte[] calcHmacSha256(byte[] secret, byte[] data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(secret, "HmacSHA256");
mac.init(secretKeySpec);
return mac.doFinal(data);
} catch (Exception e) {
throw new RuntimeException("Failed to calculate hmac-sha256", e);
}
}
public String getCalculatedSign() {
return calculatedSign;
}
public String getSign() {
return sign;
}
public boolean isValid() {
if (sign == null)
return false;
return sign.equals(calculatedSign);
}
public String getSignString() {
return result.toString();
}
private String getExpectedSign(Map<String, Object> map) {
Object elem = map.get("sign");
if (elem != null) {
return elem.toString();
} else {
return null;
}
}
private void fillSignStringForMap(Map<String, Object> map) {
List<String> sortedKeys = map.keySet().stream()
.filter(key -> !key.equalsIgnoreCase("sign"))
.sorted(String::compareToIgnoreCase)
.collect(Collectors.toList());
for (String key : sortedKeys) {
Object value = map.get(key);
if (isEmptyValue(value)) {
continue;
}
result.append(key.toLowerCase())
.append(":");
if (value instanceof Map) {
fillSignStringForMap((Map) value);
} else if (value instanceof Collection) {
fillSignStringForCollection((Collection) value);
} else if (value instanceof Array) {
fillSignStringForArray((Array) value);
} else {
result.append(value);
}
}
}
private void fillSignStringForCollection(Collection collection) {
for (Object elem : collection) {
if (!(elem instanceof Map)) {
throw new IllegalArgumentException("Collection must contain only objects.");
}
fillSignStringForMap((Map) elem);
}
}
private void fillSignStringForArray(Array array) {
int length = Array.getLength(array);
for (int i = 0; i <= length - 1; i++) {
Object elem = Array.get(array, i);
if (!(elem instanceof Map)) {
throw new IllegalArgumentException("Collection must contain only objects.");
}
fillSignStringForMap((Map) elem);
}
}
private boolean isEmptyValue(Object value) {
if (value == null) {
return true;
}
if (value instanceof Collection) {
return ((Collection) value).isEmpty();
} else if (value instanceof Map) {
return ((Map) value).isEmpty();
} else if (value instanceof Array) {
return Array.getLength(value) == 0;
} else if (value instanceof String) {
return ((String) value).isEmpty();
} else if (value instanceof Boolean) {
return !(Boolean) value;
} else if (value instanceof Number) {
return isZero((Number) value);
} else {
throw new IllegalArgumentException("Unsupported value type");
}
}
private boolean isZero(Number number) {
if (number instanceof Byte) {
return number.byteValue() == 0;
} else if (number instanceof Short) {
return number.shortValue() == 0;
} else if (number instanceof Integer) {
return number.intValue() == 0;
} else if (number instanceof Long) {
return number.longValue() == 0;
} else if (number instanceof Float) {
return number.floatValue() == 0;
} else if (number instanceof Double) {
return number.doubleValue() == 0;
} else if (number instanceof BigInteger) {
return number.equals(BigInteger.ZERO);
} else if (number instanceof BigDecimal) {
return number.equals(BigDecimal.ZERO);
} else {
throw new IllegalArgumentException("Unsupported number type");
}
}
// Должны быть включены ассерты java -ea SignChecker
public static void main(String[] args) throws IOException {
ObjectMapper om = new ObjectMapper();
String[] inputs = new String[]{
"{\n" +
" \"empty_string_key\": \"\",\n" +
" \"sign\": \"NAZEing3oTCZX8UFFjy_noJAWKUSpv2SYxPYjdGsp50=\",\n" +
" \"contacts\": [\n" +
" {\n" +
" \"last_name\": \"pupkin\",\n" +
" \"phone\": \"7991118837\",\n" +
" \"first_name\": \"vasya\",\n" +
" \"null_key_deep\": null\n" +
" },\n" +
" {\n" +
" \"first_name\": \"john\",\n" +
" \"last_name\": \"doe\",\n" +
" \"phone\": \"79992222210\"\n" +
" },\n" +
" {\n" +
" \"first_name\": \"kavychka\",\n" +
" \"last_name\": \"\\\"\",\n" +
" \"phone\": \"79992222211\"\n" +
" }\n" +
" ],\n" +
" \"zero_key\": 0,\n" +
" \"null_key\": null\n" +
"}",
"{\n" +
" \"sign\": \"LNfD638IVfC5x-XVhKXWFE7ztRRATDbLgqNgiOvefuo=\",\n" +
" \"contacts\": [\n" +
" {\"first_name\": \"FirstName\", \"last_name\": \"LastName\", \"phone\": \"PhoneNumber\"},\n" +
" {\"first_name\": \"OnlyFirstName\", \"last_name\": \"\", \"phone\": \"\"},\n" +
" {\"first_name\": \"\", \"last_name\": \"OnlyLastName\", \"phone\": \"\"},\n" +
" {\"first_name\": \"\", \"last_name\": \"\", \"phone\": \"OnlyPhoneNumber\"}\n" +
" ]\n" +
"}",
"{" +
" \"sign\": \"-eZuF5tnR65UEI-C-K3os8Jddv0wr95sOVgixTAZYWk=\",\n" +
" \"contacts\": []" +
"}"};
String[] resultString = new String[]{"contacts:first_name:vasyalast_name:pupkinphone:7991118837first_name:johnlast_name:doephone:79992222210first_name:kavychkalast_name:\"phone:79992222211",
"contacts:first_name:FirstNamelast_name:LastNamephone:PhoneNumberfirst_name:OnlyFirstNamelast_name:OnlyLastNamephone:OnlyPhoneNumber",
""};
for (int i = 0; i < 3; i++) {
Map<String, Object> map = om.readValue(inputs[i], Map.class);
SignValidator validator = new SignValidator(map, "secret");
assert validator.getSignString().equals(resultString[i]);
assert validator.isValid();
}
}
}
import com.fasterxml.jackson.databind.ObjectMapper
import java.lang.reflect.Array
import java.math.BigDecimal
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
class SignValidator {
val result = StringBuilder()
private var calculatedSign: String? = null
private var sign: String? = null
constructor(obj: Any, secret: String) {
val om = ObjectMapper()
val jsonString = om.writeValueAsString(obj)
val map = om.readValue(jsonString, Map::class.java)
calcSign(map as Map<String, Any?>, secret)
}
constructor(jsonMap: Map<String, Any?>, secret: String) {
calcSign(jsonMap, secret)
}
private fun calcSign(jsonMap: Map<String, Any?>, secret: String) {
fillSignStringForMap(jsonMap)
val signature = calcHmacSha256(
secret.toByteArray(StandardCharsets.UTF_8),
result.toString().toByteArray(StandardCharsets.UTF_8)
)
calculatedSign = Base64.getUrlEncoder().encodeToString(signature)
sign = getExpectedSign(jsonMap)
}
fun calcHmacSha256(secret: ByteArray, data: ByteArray): ByteArray {
val mac = Mac.getInstance("HmacSHA256")
val secretKeySpec = SecretKeySpec(secret, "HmacSHA256")
mac.init(secretKeySpec)
return mac.doFinal(data)
}
fun isValid(): Boolean {
if (sign == "") {
return false
}
return sign == calculatedSign
}
fun getSignString() = result.toString()
private fun getExpectedSign(map: Map<String, Any?>): String? {
val elem = map.get("sign")
if (elem != null) {
return elem.toString()
} else {
return null
}
}
private fun fillSignStringForMap(map: Map<String, Any?>) {
val sortedKeys = map.keys
.filter { key -> !key.equals("sign", ignoreCase = true) }
.sortedBy { it.lowercase() }
for (key in sortedKeys) {
val value = map[key]
if (isEmptyValue(value)) {
continue
}
result.append(key.lowercase())
.append(":")
if (value is Map<*, *>) {
fillSignStringForMap(value as Map<String, Any?>)
} else if (value is Collection<*>) {
fillSignStringForCollection(value)
} else if (value is Array) {
fillSignStringForArray(value)
} else {
result.append(value)
}
}
}
private fun fillSignStringForCollection(collection: Collection<*>) {
for (elem in collection) {
if (elem !is Map<*, *>) {
throw IllegalArgumentException("Collection must contain only objects.")
}
fillSignStringForMap(elem as Map<String, Any?>)
}
}
private fun fillSignStringForArray(array: Array) {
val length = Array.getLength(array)
for (i in 0..length - 1) {
val elem = Array.get(array, i)
if (elem !is Map<*, *>) {
throw IllegalArgumentException("Collection must contain only objects.")
}
fillSignStringForMap(elem as Map<String, Any?>)
}
}
private fun isEmptyValue(value: Any?): Boolean {
if (value == null) {
return true
}
return if (value is Collection<*>) {
value.isEmpty()
} else if (value is Map<*, *>) {
value.isEmpty()
} else if (value is Array) {
Array.getLength(value) == 0
} else if (value is String) {
value.isEmpty()
} else if (value is Boolean) {
!value
} else if (value is Number) {
isZero(value)
} else {
throw IllegalArgumentException("Unsupported value type")
}
}
private fun isZero(number: Number): Boolean {
if (number is Byte) {
return number.toByte().toInt() == 0
} else if (number is Short) {
return number.toShort().toInt() == 0
} else if (number is Int) {
return number == 0
} else if (number is Long) {
return number == 0L
} else if (number is Float) {
return number == 0.0f
} else if (number is Double) {
return number == 0.0
} else if (number is BigInteger) {
return number == BigInteger.ZERO
} else if (number is BigDecimal) {
return number == BigDecimal.ZERO
} else {
throw IllegalArgumentException("Unsupported number type")
}
}
}
fun main(args: kotlin.Array<String>) {
val om = ObjectMapper()
val inputs = arrayOf(
"""
{
"empty_string_key": "",
"sign": "NAZEing3oTCZX8UFFjy_noJAWKUSpv2SYxPYjdGsp50=",
"contacts": [
{
"last_name": "pupkin",
"phone": "7991118837",
"first_name": "vasya",
"null_key_deep": null
},
{
"first_name": "john",
"last_name": "doe",
"phone": "79992222210"
},
{
"first_name": "kavychka",
"last_name": "\"",
"phone": "79992222211"
}
],
"zero_key": 0,
"null_key": null
}
""",
"""
{
"sign": "LNfD638IVfC5x-XVhKXWFE7ztRRATDbLgqNgiOvefuo=",
"contacts": [
{"first_name": "FirstName", "last_name": "LastName", "phone": "PhoneNumber"},
{"first_name": "OnlyFirstName", "last_name": "", "phone": ""},
{"first_name": "", "last_name": "OnlyLastName", "phone": ""},
{"first_name": "", "last_name": "", "phone": "OnlyPhoneNumber"}
]
}
""",
"""
{
"sign": "-eZuF5tnR65UEI-C-K3os8Jddv0wr95sOVgixTAZYWk=",
"contacts": []
}
"""
)
val resultString = arrayOf(
"""contacts:first_name:vasyalast_name:pupkinphone:7991118837first_name:johnlast_name:doephone:79992222210first_name:kavychkalast_name:"phone:79992222211""",
"contacts:first_name:FirstNamelast_name:LastNamephone:PhoneNumberfirst_name:OnlyFirstNamelast_name:OnlyLastNamephone:OnlyPhoneNumber",
""
)
for (i in 0..2) {
val map = om.readValue(inputs[i], Map::class.java)
val validator = SignValidator(map, "secret")
assert(validator.getSignString() == resultString[i])
assert(validator.isValid())
}
}
Для упрощения тестирования вашей собственной реализации подписи мы подготовили интерактивную форму на codepen.io
Last updated