JWT с несколькими получателями

В стандартных сценариях JSON Web Token (JWT) предполагает одного получателя, который обладает ключом для проверки подписи или расшифровки содержимого. Однако спецификации семейства JOSE допускают более сложные конструкции, где один токен может быть предназначен сразу нескольким сторонам. Это достигается через механизм JSON Web Encryption (JWE) с множеством получателей.

В библиотеке jose для JavaScript подобный сценарий реализуется через формат General JSON Serialization, позволяющий включать массив получателей (recipients), каждый из которых имеет собственные параметры шифрования ключа.


Форматы сериализации JWE

Для понимания множественных получателей важно различать форматы:

  • Compact Serialization — строка с фиксированными частями, не поддерживает несколько получателей
  • Flattened JSON Serialization — JSON-объект с одним получателем
  • General JSON Serialization — JSON-объект с массивом получателей

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

{
  "protected": "base64url(...)",
  "iv": "base64url(...)",
  "ciphertext": "base64url(...)",
  "tag": "base64url(...)",
  "recipients": [
    {
      "header": { "alg": "RSA-OAEP" },
      "encrypted_key": "base64url(...)"
    },
    {
      "header": { "alg": "ECDH-ES" },
      "encrypted_key": "base64url(...)"
    }
  ]
}

Каждый получатель имеет собственный способ получения симметричного ключа, используемого для расшифровки ciphertext.


Создание JWE с несколькими получателями

Библиотека jose предоставляет класс GeneralEncrypt для формирования такого токена.

Установка

npm install jose

Импорт зависимостей

import { GeneralEncrypt, generateKeyPair } from 'jose'

Генерация ключей

const { publicKey: rsaPublicKey } = await generateKeyPair('RSA-OAEP')
const { publicKey: ecPublicKey } = await generateKeyPair('ECDH-ES')

Шифрование сообщения

const encoder = new TextEncoder()
const payload = encoder.encode('Секретное сообщение')

const jwe = await new GeneralEncrypt(payload)
  .setProtectedHeader({ enc: 'A256GCM' })
  .addRecipient(rsaPublicKey, { alg: 'RSA-OAEP' })
  .addRecipient(ecPublicKey, { alg: 'ECDH-ES' })
  .encrypt()

Результат — объект в формате General JSON, содержащий зашифрованный payload и несколько encrypted_key.


Механизм работы

  1. Генерируется случайный Content Encryption Key (CEK).

  2. Payload шифруется с помощью CEK (например, A256GCM).

  3. Для каждого получателя:

    • CEK шифруется его публичным ключом
    • создаётся отдельный encrypted_key
  4. Все encrypted_key включаются в массив recipients.

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


Расшифровка JWE

Получатель использует свой приватный ключ. Библиотека автоматически находит соответствующий recipient.

import { generalDecrypt } from 'jose'

const { plaintext } = await generalDecrypt(jwe, privateKey)

const decoded = new TextDecoder().decode(plaintext)

Если ключ соответствует одному из получателей — расшифровка проходит успешно.


Обработка нескольких ключей

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

const result = await generalDecrypt(jwe, async (protectedHeader, recipient) => {
  if (recipient.header.alg === 'RSA-OAEP') {
    return rsaPrivateKey
  }
  if (recipient.header.alg === 'ECDH-ES') {
    return ecPrivateKey
  }
})

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


Заголовки и их роль

В JWE используются три уровня заголовков:

  • protected — общий для всех получателей (например, enc)
  • unprotected — общий, но не защищённый
  • recipient header — индивидуальный для каждого получателя

Пример:

.addRecipient(key, {
  alg: 'RSA-OAEP',
  kid: 'key-id-1'
})

kid помогает идентифицировать ключ при расшифровке.


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

JWT с несколькими получателями фактически является JWE, содержащим полезную нагрузку JWT.

const payload = JSON.stringify({
  sub: '123',
  role: 'admin'
})

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


Ограничения и особенности

  • Размер токена значительно увеличивается с числом получателей
  • Не поддерживается Compact формат
  • Все получатели получают одинаковый payload
  • Нельзя назначить разные payload для разных получателей в одном токене

Практические сценарии

1. Рассылка защищённых данных нескольким сервисам

Один токен может быть расшифрован:

  • backend-сервисом
  • аналитической системой
  • системой логирования

2. Переход между ключами (key rotation)

Можно добавить:

  • старый ключ
  • новый ключ

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

3. Мульти-tenant системы

Каждый клиент имеет свой ключ, но получает одинаковый защищённый payload.


Выбор алгоритмов

  • RSA-OAEP — широко поддерживается, но медленнее
  • ECDH-ES — быстрее, требует согласования ключей
  • A256GCM — стандарт для шифрования payload

Комбинации:

alg enc Назначение
RSA-OAEP A256GCM универсальный вариант
ECDH-ES A256GCM высокая производительность

Безопасность

  • CEK генерируется случайно для каждого токена
  • Каждый encrypted_key независим
  • Компрометация одного получателя не влияет на других
  • Заголовки должны валидироваться при расшифровке

Отладка и диагностика

Полезно декодировать JWE без расшифровки:

import { decodeProtectedHeader } from 'jose'

const header = decodeProtectedHeader(jwe)
console.log(header)

Анализ recipients:

console.log(jwe.recipients)

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

С увеличением числа получателей:

  • возрастает время шифрования
  • увеличивается размер JSON
  • возрастает нагрузка на сеть

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

  • ограничивать число получателей
  • использовать ECDH-ES вместо RSA при возможности

Сравнение с JWS

Характеристика JWS JWE с несколькими получателями
Подпись Да Нет
Шифрование Нет Да
Несколько получателей Нет Да
Конфиденциальность Нет Да

Комбинирование JWS и JWE

Часто применяется схема:

  1. Подписать payload (JWS)
  2. Зашифровать результат (JWE с несколькими получателями)
// JWS → JWE

Это обеспечивает:

  • целостность (подпись)
  • конфиденциальность (шифрование)
  • доступ нескольким получателям

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

При использовании JWE с A256GCM встроена аутентификация данных через тег (tag). Это гарантирует, что:

  • данные не были изменены
  • заголовки соответствуют payload

Расширенные возможности

  • добавление aad (Additional Authenticated Data)
  • кастомные заголовки
  • интеграция с JWKS (наборы ключей)

Работа с JWKS

Можно использовать набор ключей:

import { createLocalJWKSet } from 'jose'

const jwks = createLocalJWKSet({
  keys: [/* массив ключей */]
})

await generalDecrypt(jwe, jwks)

Библиотека сама подберёт нужный ключ.


Ошибки и их обработка

Типичные ошибки:

  • JWEDecryptionFailed — ключ не подошёл
  • JWEInvalid — структура токена нарушена
  • JOSENotSupported — алгоритм не поддерживается

Обработка:

try {
  await generalDecrypt(jwe, key)
} catch (e) {
  console.error(e)
}

Итоговая модель

JWT с несколькими получателями — это:

  • JWE в формате General JSON
  • один payload
  • несколько способов доступа к CEK
  • независимые получатели

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