Подпись JWT-подобных токенов на Ed25519

Ed25519 относится к классу эллиптических цифровых подписей с высокой скоростью вычисления и устойчивостью к типичным ошибкам реализации. В контексте JavaScript экосистемы он часто используется через реализации NaCl, такие как TweetNaCl.js, где доступен набор криптографических примитивов, достаточных для построения подписей и проверки целостности данных без привлечения внешних криптографических сервисов.

JWT-подобные токены обычно состоят из трёх частей: заголовка, полезной нагрузки и подписи. Даже если формат не является строго RFC 7519-совместимым, структура сохраняется:

  • header — метаданные алгоритма
  • payload — данные утверждений (claims)
  • signature — криптографическая подпись

Ключевая особенность при использовании Ed25519 заключается в том, что подпись формируется над байтовым представлением строки base64url(header) + "." + base64url(payload), а не над JSON напрямую.


JWT-подобные структуры не используют стандартный base64, так как он содержит символы +, / и padding =. Вместо этого применяется base64url:

function base64urlEncode(buffer) {
  return Buffer.from(buffer)
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_');
}

function base64urlDecode(str) {
  str = str.replace(/-/g, '+').replace(/_/g, '/');
  const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4));
  return Buffer.from(str + pad, 'base64');
}

В браузерной среде вместо Buffer используется Uint8Array и atob/btoa, но логика трансформации сохраняется.


Формирование структуры токена

JWT-подобный токен в упрощённой форме:

base64url(header).base64url(payload).base64url(signature)

Заголовок фиксирует алгоритм:

const header = {
  alg: "Ed25519",
  typ: "JWT"
};

Полезная нагрузка содержит произвольные claims:

const payload = {
  sub: "user-123",
  iat: Math.floor(Date.now() / 1000),
  role: "admin"
};

Сериализация:

const encodedHeader = base64urlEncode(JSON.stringify(header));
const encodedPayload = base64urlEncode(JSON.stringify(payload));

const signingInput = `${encodedHeader}.${encodedPayload}`;

Подпись через TweetNaCl.js

TweetNaCl.js предоставляет реализацию Ed25519 через функции nacl.sign и nacl.sign.detached.

Для подписания JWT-подобного токена используется именно detached-режим, поскольку требуется отдельно хранить подпись:

import nacl from "tweetnacl";

nacl.util = require("tweetnacl-util");

function signToken(payload, secretKey) {
  const header = {
    alg: "Ed25519",
    typ: "JWT"
  };

  const encodedHeader = base64urlEncode(JSON.stringify(header));
  const encodedPayload = base64urlEncode(JSON.stringify(payload));

  const data = `${encodedHeader}.${encodedPayload}`;

  const messageBytes = nacl.util.decodeUTF8(data);
  const signature = nacl.sign.detached(messageBytes, secretKey);

  const encodedSignature = base64urlEncode(signature);

  return `${data}.${encodedSignature}`;
}

Здесь secretKey — это 64-байтовый приватный ключ Ed25519, включающий seed и публичную часть, как это принято в NaCl-формате.


Проверка подписи

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

function verifyToken(token, publicKey) {
  const parts = token.split(".");
  if (parts.length !== 3) return false;

  const [encodedHeader, encodedPayload, encodedSignature] = parts;

  const data = `${encodedHeader}.${encodedPayload}`;

  const messageBytes = nacl.util.decodeUTF8(data);
  const signatureBytes = base64urlDecode(encodedSignature);

  return nacl.sign.detached.verify(
    messageBytes,
    signatureBytes,
    publicKey
  );
}

Проверка не восстанавливает приватный ключ и не расшифровывает данные, а только подтверждает, что подпись соответствует сообщению и публичному ключу.


Особенности Ed25519 в контексте JWT-подобных токенов

Ed25519 отличается от RSA и ECDSA рядом свойств, которые напрямую влияют на архитектуру токенов:

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

Из-за этого отпадает необходимость в сложных параметрах, характерных для ECDSA, где ошибки генерации nonce приводят к компрометации ключей.


Интеграция с NaCl-форматом ключей

TweetNaCl.js использует бинарные ключи фиксированного размера:

  • publicKey: 32 байта
  • secretKey: 64 байта

Генерация:

const keyPair = nacl.sign.keyPair();

const publicKey = keyPair.publicKey;
const secretKey = keyPair.secretKey;

Хранение ключей требует строгого разделения: приватный ключ не должен попадать в клиентский код при моделях с серверной подписью токенов.


Ограничения JWT-подобного подхода с Ed25519

При использовании Ed25519 в формате JWT-подобных структур возникают архитектурные особенности:

  • отсутствие возможности частичной валидации payload без подписи
  • невозможность изменения claims без полной перегенерации подписи
  • чувствительность к любому изменению сериализации JSON

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


Детерминированная сериализация JSON

Для стабильной подписи применяется сортировка ключей:

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

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

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

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

const encodedPayload = base64urlEncode(stableStringify(payload));

Совместимость с классическим JWT

Хотя структура напоминает JWT, различия принципиальны:

  • JWT (JWS) часто использует base64url(header).base64url(payload).signature, но алгоритмы могут быть RSA/ECDSA/HMAC
  • Ed25519 требует строгой работы с байтами через NaCl
  • многие JWT-библиотеки не поддерживают Ed25519 нативно или реализуют его через внешние зависимости

Это приводит к тому, что такие токены чаще рассматриваются как “JWT-like”, а не стандартные JWT.


Типовой поток работы в API

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

  1. Генерация токена:

    • формирование payload
    • сериализация header/payload
    • подпись приватным ключом
  2. Проверка:

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

Использование TweetNaCl.js в браузерных условиях

TweetNaCl.js компактен и не требует WebCrypto API, что делает его применимым в средах с ограниченным доступом к криптографическим функциям платформы.

При этом все операции происходят синхронно и детерминированно, что упрощает интеграцию в event-driven архитектуры фронтенда.


Ошибки реализации, влияющие на безопасность

Некорректные реализации чаще всего связаны не с самим Ed25519, а с обвязкой:

  • использование нестабильного JSON.stringify
  • отсутствие base64url-санитизации
  • повторное кодирование байтовых массивов
  • смешивание UTF-8 и binary представлений
  • хранение приватного ключа в клиентской среде

Каждая из этих ошибок приводит к либо невалидным токенам, либо к полной компрометации модели доверия.


Разделение ответственности между слоями системы

Архитектурно подпись JWT-подобных токенов на Ed25519 обычно размещается в отдельном сервисе, который выполняет только одну задачу — криптографическое подписание и верификацию. Бизнес-логика при этом не должна участвовать в формировании ключей или сериализации, кроме формирования исходного payload.