Подпись JSON-структур и произвольных бинарных данных

В криптографических примитивах NaCl (Networking and Cryptography library) и её JavaScript-реализации TweetNaCl.js механизм цифровой подписи строится на алгоритме Ed25519. Библиотека предоставляет высокоуровневые функции nacl.sign и nacl.sign.detached, позволяющие подписывать как произвольные сообщения, так и их «отсоединённые» подписи.

Подпись в контексте JavaScript почти всегда сталкивается с двумя проблемами: представление бинарных данных и стабильность сериализации структурированных объектов. Особенно критично это при работе с JSON, где даже незначительное изменение порядка ключей приводит к совершенно другой подписи.


Бинарное представление данных в TweetNaCl.js

TweetNaCl.js работает исключительно с байтовыми массивами:

  • Uint8Array — основной формат входных и выходных данных
  • Любые строки должны быть явно закодированы (обычно UTF-8)
  • Любые сложные структуры (JSON, объекты) требуют сериализации

Подпись всегда вычисляется над последовательностью байтов, а не над «логическим объектом».

const message = new TextEncoder().encode("hello");
const keyPair = nacl.sign.keyPair();

const signature = nacl.sign.detached(message, keyPair.secretKey);

Функция detached возвращает только подпись, не изменяя исходные данные.


Подпись JSON-структур и проблема канонизации

При подписании JSON-структур возникает фундаментальная проблема: JSON не имеет стандарта канонического представления.

Следующие объекты логически равны, но дают разные подписи:

{"a":1,"b":2}
{"b":2,"a":1}

В Jav * aScript:

const obj1 = { a: 1, b: 2 };
const obj2 = { b: 2, a: 1 };

JSON.stringify(obj1) !== JSON.stringify(obj2)

Это означает, что без дополнительной нормализации подпись становится нестабильной.


Каноническая сериализация JSON

Для корректной подписи требуется детерминированное преобразование объекта в строку.

Простейший подход — сортировка ключей:

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

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

  const keys = Object.keys(obj).sort();
  return `{${keys.map(k => JSON.stringify(k) + ":" + canonicalStringify(obj[k])).join(",")}}`;
}

Теперь сериализация становится стабильной:

const data = { b: 2, a: 1 };
const encoded = new TextEncoder().encode(canonicalStringify(data));

const signature = nacl.sign.detached(encoded, keyPair.secretKey);

Проверка подписи JSON-данных

Проверка выполняется через nacl.sign.detached.verify, где важно использовать ту же самую канонизацию.

const isValid = nacl.sign.detached.verify(
  new TextEncoder().encode(canonicalStringify(data)),
  signature,
  keyPair.publicKey
);

Любое расхождение в сериализации приводит к отрицательному результату проверки, даже если структура объекта логически идентична.


Подпись произвольных бинарных данных

TweetNaCl.js не делает различий между текстом и бинарными данными. Любая последовательность байтов может быть подписана напрямую.

Подпись файлов или буферов

const fileBytes = new Uint8Array(fileArrayBuffer);

const signature = nacl.sign.detached(fileBytes, secretKey);

Такая модель используется для:

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

Кодирование бинарных данных для JSON

JSON не поддерживает бинарные данные. Поэтому подпись и данные часто передаются вместе в сериализованном виде:

Base64 представление

function toBase64(uint8) {
  return btoa(String.fromCharCode(...uint8));
}

Пример структуры:

const packet = {
  data: toBase64(fileBytes),
  signature: toBase64(signature)
};

Обратное преобразование:

function fromBase64(str) {
  return Uint8Array.from(atob(str), c => c.charCodeAt(0));
}

Типовая структура подписанного сообщения

В реальных протоколах часто используется единая структура:

const message = {
  payload: { userId: 42, action: "transfer", amount: 100 },
  timestamp: 1710000000
};

Подпись вычисляется не над объектом, а над канонической сериализацией:

const bytes = new TextEncoder().encode(canonicalStringify(message));

const signature = nacl.sign.detached(bytes, secretKey);

И передача:

const envelope = {
  message,
  signature: toBase64(signature)
};

Частые ошибки при подписании JSON

Изменение порядка ключей

Даже одинаковые по смыслу объекты дают разные подписи без сортировки ключей.

Непоследовательное форматирование чисел

1 и 1.0 могут сериализоваться одинаково в JSON, но различаться в пользовательских схемах.

Наличие пробелов и форматирования

JSON.stringify не гарантирует единый стиль между средами, если используется кастомная логика.

Подпись уже сериализованной строки без фиксации формата

Если строка формируется разными способами (например, через шаблонные строки), подпись теряет смысл.


Подпись потоков данных и больших буферов

TweetNaCl.js не поддерживает потоковую подпись напрямую, поэтому данные обычно предварительно агрегируются:

async function readToUint8(file) {
  return new Uint8Array(await file.arrayBuffer());
}

Для больших данных это приводит к необходимости хранения полного буфера в памяти перед подписью.


Детерминизм как основа криптографической корректности

Подпись в Ed25519 всегда детерминирована: одинаковые входные байты дают одинаковую подпись.

Следовательно, вся ответственность за стабильность результата лежит не на алгоритме, а на:

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

Любая вариативность до этапа подписи разрушает криптографическую гарантию.


Совмещение JSON и бинарных данных в одном протоколе

Распространённый подход — разделение слоя данных и слоя подписи:

  • JSON используется только для метаданных
  • бинарные данные подписываются отдельно
  • итоговый пакет содержит оба представления
const payloadBytes = fileBytes;
const metaBytes = new TextEncoder().encode(canonicalStringify(metadata));

const signature = nacl.sign.detached(
  concat(metaBytes, payloadBytes),
  secretKey
);

Где concat объединяет два Uint8Array в один буфер.


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

Подпись в TweetNaCl.js имеет фиксированный размер 64 байта. В бинарном виде она не зависит от длины сообщения, что упрощает хранение и передачу.

Для JSON-передачи чаще всего используется Base64 или hex-строка, но это уже слой над криптографией, а не её часть.