Практика: подпись API-ответов

Структура защищённого API-ответа

При работе с API, где требуется гарантия целостности и подлинности данных, ответ обычно дополняется криптографической подписью. Такой подход позволяет клиенту проверить, что данные:

  • не были изменены в пути
  • действительно сформированы доверенным сервером
  • соответствуют ожидаемому формату на момент подписи

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

  • payload — полезные данные
  • signature — криптографическая подпись

Пример логической структуры:

{
  "payload": {
    "userId": 42,
    "role": "admin",
    "iat": 1710000000
  },
  "signature": "MEUCIQDx...."
}

Выбор алгоритма подписи в Jsrsasign

Библиотека Jsrsasign поддерживает несколько криптографических алгоритмов. Для API-подписей чаще всего используются:

  • RSA (RS256, RS512)
  • ECDSA (ES256)

Наиболее распространённый вариант — RSA с SHA-256.

Ключевая схема:

SHA256 + RSA (RS256)

Она обеспечивает баланс между производительностью и криптографической стойкостью.

Генерация ключевой пары

Для подписи требуется пара ключей:

  • приватный ключ (используется сервером)
  • публичный ключ (используется клиентом для проверки)

Пример генерации RSA-ключей через Jsrsasign:

const KEYUTIL = require("jsrsasign").KEYUTIL;

const kp = KEYUTIL.generateKeypair("RSA", 2048);

const privateKeyPEM = KEYUTIL.getPEM(kp.prvKeyObj, "PKCS8PRV");
const publicKeyPEM = KEYUTIL.getPEM(kp.pubKeyObj);

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

Формирование строки для подписи

Перед подписью JSON-объект необходимо привести к детерминированному виду. Любое изменение порядка полей может привести к разной подписи.

Используется правило:

  • сериализация через JSON.stringify
  • фиксированный порядок ключей

Пример:

function canonicalize(obj) {
  return JSON.stringify(obj, Object.keys(obj).sort());
}

Создание подписи ответа

Jsrsasign предоставляет класс KJUR.crypto.Signature.

Процесс подписи включает:

  1. выбор алгоритма
  2. инициализацию приватного ключа
  3. обновление данных
  4. генерацию подписи

Пример реализации:

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

function signResponse(payload, privateKeyPEM) {
  const signature = new KJUR.crypto.Signature({ alg: "SHA256withRSA" });
  signature.init(privateKeyPEM);

  const data = canonicalize(payload);
  signature.updateString(data);

  return signature.sign();
}

Результат подписи обычно кодируется в Base64 или Hex.

Формирование полного API-ответа

После генерации подписи структура собирается следующим образом:

function createSignedResponse(payload, privateKeyPEM) {
  const dataString = canonicalize(payload);

  const sig = new KJUR.crypto.Signature({ alg: "SHA256withRSA" });
  sig.init(privateKeyPEM);
  sig.updateString(dataString);

  const signature = sig.sign();

  return {
    payload,
    signature
  };
}

Проверка подписи на стороне клиента

Проверка выполняется с использованием публичного ключа.

Алгоритм:

  1. извлечение payload
  2. повторная сериализация
  3. проверка подписи

Пример:

function verifyResponse(response, publicKeyPEM) {
  const sig = new KJUR.crypto.Signature({ alg: "SHA256withRSA" });
  sig.init(publicKeyPEM);

  const data = canonicalize(response.payload);
  sig.updateString(data);

  return sig.verify(response.signature);
}

Результат true означает, что данные не были изменены.

Подпись HTTP-ответов на уровне middleware

В серверных приложениях подпись часто интегрируется в middleware-слой.

Пример для Node.js:

function signingMiddleware(req, res, next) {
  const originalJson = res.json;

  res.json = function (data) {
    const signed = createSignedResponse(data, process.env.PRIVATE_KEY);
    originalJson.call(this, signed);
  };

  next();
}

Такой подход обеспечивает автоматическую подпись всех API-ответов без изменения бизнес-логики.

Подпись частичных данных ответа

Иногда требуется подписывать не весь ответ, а только его часть. Например:

  • только бизнес-данные без метаинформации
  • исключение временных полей
  • стабильная подпись независимо от служебных атрибутов

Пример:

const payloadToSign = {
  userId: data.userId,
  balance: data.balance
};

Остальные поля (например, timestamp, requestId) исключаются из подписи.

Использование Base64 для транспортировки подписи

Для безопасной передачи подписи часто используется Base64:

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

На стороне проверки выполняется обратное преобразование:

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

Типичные ошибки при подписании API-ответов

Основные проблемы возникают не в криптографии, а в работе с данными:

  • изменение порядка ключей JSON
  • различия в форматировании (пробелы, переносы строк)
  • подпись не того объекта (например, всего ответа вместо payload)
  • использование разных алгоритмов на клиенте и сервере
  • утечка приватного ключа в клиентский код

Даже незначительное отличие сериализации делает подпись недействительной.

Стабильная сериализация как обязательное условие

Для корректной работы схемы подписи используется принцип детерминированной сериализации:

  • сортировка ключей
  • отсутствие форматирования
  • одинаковая кодировка UTF-8
  • единый способ обработки чисел и строк

Пример упрощённого канонизатора:

function stableStringify(obj) {
  if (obj === null || typeof obj !== "object") {
    return JSON.stringify(obj);
  }

  if (Array.isArray(obj)) {
    return `[${obj.map(stableStringify).join(",")}]`;
  }

  return `{${Object.keys(obj)
    .sort()
    .map(k => `"${k}":${stableStringify(obj[k])}`)
    .join(",")}}`;
}

Интеграция с JWT-структурами

Подпись API-ответов часто пересекается с концепцией JWT, хотя это разные уровни абстракции.

В Jsrsasign можно использовать JWT как альтернативу ручной подписи:

const header = { alg: "RS256", typ: "JWT" };
const payload = { userId: 42 };

const token = KJUR.jws.JWS.sign(
  "RS256",
  JSON.stringify(header),
  JSON.stringify(payload),
  privateKeyPEM
);

Однако в контексте API-ответов часто требуется более гибкий контроль структуры, поэтому ручная подпись остаётся предпочтительной.

Проверка целостности данных при кэшировании

Подписи особенно полезны при использовании кэшей:

  • CDN
  • Redis
  • локальных HTTP-кэшей

Клиент может доверять закэшированному ответу, если подпись остаётся валидной.

Схема:

API → CDN cache → клиент → проверка подписи → использование данных

Защита от подмены данных в промежуточных слоях

Подписанный ответ обеспечивает защиту от:

  • модификации прокси-серверами
  • подмены ответов на уровне сетевого оборудования
  • атак типа “man-in-the-middle” (внутри доверенной инфраструктуры)

Даже если данные изменены, проверка подписи сразу выявляет нарушение целостности.

Производительность подписи в Jsrsasign

RSA-подпись является относительно тяжёлой операцией. В реальных системах учитываются:

  • размер ключа (2048 vs 4096)
  • частота генерации подписи
  • нагрузка на API

Оптимизации:

  • кэширование сериализованных строк
  • вынесение подписи в отдельный сервис
  • использование более быстрых алгоритмов при необходимости (ECDSA)

Расширение схемы подписи метаданными

В сложных API добавляются дополнительные поля:

{
  "payload": { },
  "signature": "...",
  "algorithm": "RS256",
  "keyId": "api-key-2026",
  "timestamp": 1710000000
}

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