В криптографических примитивах NaCl (Networking and Cryptography
library) и её JavaScript-реализации TweetNaCl.js механизм цифровой
подписи строится на алгоритме Ed25519. Библиотека предоставляет
высокоуровневые функции nacl.sign и
nacl.sign.detached, позволяющие подписывать как
произвольные сообщения, так и их «отсоединённые» подписи.
Подпись в контексте JavaScript почти всегда сталкивается с двумя проблемами: представление бинарных данных и стабильность сериализации структурированных объектов. Особенно критично это при работе с JSON, где даже незначительное изменение порядка ключей приводит к совершенно другой подписи.
TweetNaCl.js работает исключительно с байтовыми массивами:
Uint8Array — основной формат входных и выходных
данныхПодпись всегда вычисляется над последовательностью байтов, а не над «логическим объектом».
const message = new TextEncoder().encode("hello");
const keyPair = nacl.sign.keyPair();
const signature = nacl.sign.detached(message, keyPair.secretKey);
Функция detached возвращает только подпись, не изменяя
исходные данные.
При подписании 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)
Это означает, что без дополнительной нормализации подпись становится нестабильной.
Для корректной подписи требуется детерминированное преобразование объекта в строку.
Простейший подход — сортировка ключей:
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);
Проверка выполняется через 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 не поддерживает бинарные данные. Поэтому подпись и данные часто передаются вместе в сериализованном виде:
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)
};
Даже одинаковые по смыслу объекты дают разные подписи без сортировки ключей.
1 и 1.0 могут сериализоваться одинаково в
JSON, но различаться в пользовательских схемах.
JSON.stringify не гарантирует единый стиль между
средами, если используется кастомная логика.
Если строка формируется разными способами (например, через шаблонные строки), подпись теряет смысл.
TweetNaCl.js не поддерживает потоковую подпись напрямую, поэтому данные обычно предварительно агрегируются:
async function readToUint8(file) {
return new Uint8Array(await file.arrayBuffer());
}
Для больших данных это приводит к необходимости хранения полного буфера в памяти перед подписью.
Подпись в Ed25519 всегда детерминирована: одинаковые входные байты дают одинаковую подпись.
Следовательно, вся ответственность за стабильность результата лежит не на алгоритме, а на:
Любая вариативность до этапа подписи разрушает криптографическую гарантию.
Распространённый подход — разделение слоя данных и слоя подписи:
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-строка, но это уже слой над криптографией, а не её часть.