Практика: проверка целостности данных через HMAC

HMAC (Hash-based Message Authentication Code) используется для подтверждения того, что сообщение не было изменено и действительно создано обладателем общего секретного ключа. В отличие от обычных хеш-функций, HMAC защищён от подмены входных данных благодаря использованию криптографического ключа.

Основная идея: одинаковые входные данные дают одинаковый HMAC только при наличии одного и того же секретного ключа.


Модель угроз и задачи целостности

При передаче данных через API, WebSocket или любые открытые каналы возникают типовые угрозы:

  • изменение параметров запроса по пути
  • подмена тела сообщения
  • повторная отправка ранее перехваченного запроса (replay attack)
  • попытка генерации валидной подписи без знания секрета

HMAC решает задачу проверки:

  • целостности данных
  • аутентичности отправителя

Jsrsasign в контексте HMAC

Библиотека jsrsasign предоставляет криптографические примитивы для работы в JavaScript как в браузере, так и в Node.js. Она включает поддержку:

  • SHA-хешей
  • HMAC
  • RSA/DSA/ECDSA
  • PEM/DER форматов

Для HMAC используется модуль KJUR.crypto.Mac.


Установка и подключение библиотеки

npm install jsrsasign

Использование в Node.js:

const jsrsasign = require("jsrsasign");

В браузере (через bundle):

<script src="https://cdnjs.cloudflare.com/ajax/libs/jsrsasign/10.8.6/jsrsasign-all-min.js"></script>

Базовое вычисление HMAC-SHA256

Основная операция — генерация MAC (message authentication code):

const { KJUR } = require("jsrsasign");

const secretKey = "super_secret_key";
const message = "user_id=42&role=admin";

const hmac = new KJUR.crypto.Mac({
  alg: "HmacSHA256",
  pass: secretKey
});

hmac.updateString(message);

const signatureHex = hmac.doFinal();

console.log(signatureHex);

Форматы вывода: HEX и Base64

Jsrsasign по умолчанию возвращает значение в HEX. Однако в API чаще используют Base64.

HEX → Base64

const hex = signatureHex;

const base64 = Buffer.from(hex, "hex").toString("base64");

console.log(base64);

Для браузера:

const base64 = KJUR.crypto.Util.hex2b64(signatureHex);

Подпись JSON-данных

При работе с API важно сохранять детерминированность строки.

Проблема: JSON не гарантирует порядок ключей.

Решение: формирование канонической строки.

function canonicalString(obj) {
  return Object.keys(obj)
    .sort()
    .map(key => `${key}=${obj[key]}`)
    .join("&");
}

const payload = {
  user_id: 42,
  role: "admin",
  timestamp: 1714650000
};

const message = canonicalString(payload);

Далее вычисляется HMAC:

const mac = new KJUR.crypto.Mac({
  alg: "HmacSHA256",
  pass: secretKey
});

mac.updateString(message);

const signature = mac.doFinal();

Проверка HMAC на стороне сервера

Проверка заключается в повторном вычислении подписи и сравнении значений.

function verifyHmac(message, receivedSignatureHex, secretKey) {
  const mac = new KJUR.crypto.Mac({
    alg: "HmacSHA256",
    pass: secretKey
  });

  mac.updateString(message);

  const expected = mac.doFinal();

  return expected === receivedSignatureHex;
}

Безопасное сравнение подписей

Прямое сравнение строк может быть уязвимо к timing attack.

Более корректный подход — побайтовое сравнение:

function constantTimeEqual(a, b) {
  if (a.length !== b.length) return false;

  let result = 0;

  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }

  return result === 0;
}

Использование HMAC в HTTP API

Типичный сценарий:

Клиент формирует запрос:

const payload = {
  user_id: 42,
  action: "update_profile",
  timestamp: Date.now()
};

const message = canonicalString(payload);

const mac = new KJUR.crypto.Mac({
  alg: "HmacSHA256",
  pass: secretKey
});

mac.updateString(message);

const signature = mac.doFinal();

Отправка:

fetch("/api/profile", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Signature": signature
  },
  body: JSON.stringify(payload)
});

Проверка на сервере

app.post("/api/profile", (req, res) => {
  const signature = req.headers["x-signature"];

  const message = canonicalString(req.body);

  const mac = new KJUR.crypto.Mac({
    alg: "HmacSHA256",
    pass: secretKey
  });

  mac.updateString(message);

  const expected = mac.doFinal();

  if (!constantTimeEqual(expected, signature)) {
    return res.status(401).send("Invalid signature");
  }

  res.send("OK");
});

Replay-атаки и защита через timestamp

HMAC не защищает от повторной отправки запроса. Для этого добавляется временная метка:

const payload = {
  user_id: 42,
  timestamp: Date.now()
};

На сервере:

  • проверяется разница времени
  • отклоняются устаревшие запросы (например > 5 минут)

Частые ошибки при работе с HMAC в Jsrsasign

1. Разный порядок параметров Изменение порядка ключей ломает подпись.

2. Разные кодировки UTF-8 vs ASCII приводит к разным результатам.

mac.updateString(message); // всегда строка, не Buffer

3. Несогласованный формат подписи HEX на одной стороне, Base64 на другой.

4. Использование нестабильного JSON

JSON.stringify(obj) // опасно без сортировки ключей

Работа с бинарными данными

Jsrsasign позволяет работать не только со строками:

mac.updateHex("deadbeef");

или:

mac.updateString("binary-safe-string");

Но в большинстве API сценариев предпочтительнее работать со строками UTF-8.


Использование разных алгоритмов HMAC

Jsrsasign поддерживает:

  • HmacSHA1
  • HmacSHA256
  • HmacSHA512

Пример смены алгоритма:

const mac = new KJUR.crypto.Mac({
  alg: "HmacSHA512",
  pass: secretKey
});

Выбор зависит от требований безопасности. SHA256 является стандартом для большинства API.


Управление секретным ключом

Ключ HMAC — критически важный элемент безопасности:

  • не хранится в клиентском коде
  • передаётся только через защищённые каналы
  • ротируется при компрометации
  • может быть уникальным для каждого пользователя или приложения

Структура надёжной схемы подписи

Типичная промышленная схема включает:

  • canonical string (строго определённый формат)
  • timestamp
  • nonce (одноразовый идентификатор)
  • HMAC-SHA256
  • проверка на сервере + защита от replay

Такая комбинация обеспечивает устойчивость к основным типам атак на целостность данных в прикладных API.