Множественные подписи в General JWS

Спецификация JWS (JSON Web Signature) определяет несколько форматов представления подписанных данных. В отличие от компактного (Compact Serialization), формат General JSON Serialization позволяет включать несколько подписей для одного и того же полезного содержимого (payload). Это особенно важно в сценариях, где требуется:

  • Подпись несколькими сторонами (multi-party signing)
  • Поддержка разных алгоритмов подписи
  • Постепенное добавление подписей
  • Проверка доверия от разных источников

Структура General JWS представляет собой JSON-объект:

{
  "payload": "<base64url-encoded payload>",
  "signatures": [
    {
      "protected": "<base64url-encoded header>",
      "header": { ... },
      "signature": "<base64url-encoded signature>"
    },
    ...
  ]
}

Особенности структуры

  • payload — общий для всех подписей

  • signatures — массив объектов подписи

  • Каждая подпись может иметь:

    • protected — защищённый заголовок (участвует в подписи)
    • header — незащищённый заголовок
    • signature — сама подпись

Каждая подпись формируется независимо, но применяется к одному и тому же payload.


Работа с General JWS в библиотеке jose

Библиотека jose предоставляет API для создания и проверки JWS с множественными подписями через класс GeneralSign.

Установка

npm install jose

Создание JWS с несколькими подписями

import { GeneralSign } from 'jose'
import { generateKeyPair } from 'crypto'

const { privateKey: key1 } = await generateKeyPair('rsa', {
  modulusLength: 2048,
})

const { privateKey: key2 } = await generateKeyPair('ec', {
  namedCurve: 'P-256',
})

const payload = new TextEncoder().encode('Multi-signature payload')

const jws = await new GeneralSign(payload)
  .addSignature(key1)
  .setProtectedHeader({ alg: 'RS256' })
  .addSignature(key2)
  .setProtectedHeader({ alg: 'ES256' })
  .sign()

console.log(jws)

Разбор примера

  • GeneralSign(payload) — инициализация с полезной нагрузкой
  • addSignature(key) — добавление новой подписи
  • setProtectedHeader(...) — установка заголовка для текущей подписи
  • .sign() — генерация финального JWS-объекта

Важно: вызов setProtectedHeader применяется только к последней добавленной подписи.


Добавление разных типов заголовков

Каждая подпись может иметь:

  • protected header — кодируется и участвует в подписи
  • unprotected header — не участвует в подписи
.addSignature(key1)
.setProtectedHeader({ alg: 'RS256' })
.setUnprotectedHeader({ kid: 'key1-id' })

Проверка General JWS

Проверка производится с использованием generalVerify:

import { generalVerify } from 'jose'

const { payload, signatures } = await generalVerify(jws, async (protectedHeader) => {
  if (protectedHeader.alg === 'RS256') return publicKey1
  if (protectedHeader.alg === 'ES256') return publicKey2
})

Механизм выбора ключа

Функция-резолвер получает protectedHeader и возвращает соответствующий публичный ключ. Это позволяет:

  • Поддерживать разные алгоритмы
  • Выбирать ключ по alg, kid, iss и др.

Проверка отдельных подписей

generalVerify возвращает:

  • payload — исходные данные
  • signatures — массив успешно проверенных подписей

Каждый элемент содержит:

{
  protectedHeader,
  unprotectedHeader,
  signature
}

Обработка частично валидных подписей

По умолчанию generalVerify требует, чтобы все подписи были валидны. Однако можно изменить поведение:

await generalVerify(jws, keyResolver, {
  complete: true
})

Это позволяет анализировать каждую подпись отдельно, даже если некоторые из них недействительны.


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

Поле kid (Key ID) помогает выбирать ключ:

.setProtectedHeader({
  alg: 'RS256',
  kid: 'key-1'
})

Резолвер:

const keyResolver = async (header) => {
  return keyStore[header.kid]
}

Поддержка разных алгоритмов

В одном JWS можно комбинировать:

  • RSA (RS256)
  • ECDSA (ES256)
  • EdDSA

Пример:

.addSignature(rsaKey)
.setProtectedHeader({ alg: 'RS256' })

.addSignature(ecKey)
.setProtectedHeader({ alg: 'ES256' })

.addSignature(edKey)
.setProtectedHeader({ alg: 'EdDSA' })

Кодирование payload

Payload кодируется автоматически в Base64URL. При необходимости можно отключить это:

.setProtectedHeader({
  alg: 'HS256',
  b64: false,
  crit: ['b64']
})

Тогда payload передаётся как есть (не закодированным), что требует соблюдения RFC 7797.


Безопасность и валидация

Ключевые аспекты:

  • Проверка alg должна быть строгой
  • Не доверять незащищённым заголовкам (header)
  • Использовать kid только вместе с проверкой источника
  • Валидировать все подписи, если требуется высокий уровень доверия

Типичные сценарии применения

1. Мульти-подпись документов

  • Несколько сторон подписывают один документ

2. Федеративные системы

  • Разные организации добавляют свои подписи

3. Миграция алгоритмов

  • Поддержка старых и новых алгоритмов одновременно

4. Кросс-платформенная верификация

  • Разные системы используют разные криптографические стандарты

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

  • Размер JWS увеличивается с каждой подписью
  • Проверка требует больше вычислений
  • Не все системы поддерживают General Serialization
  • Требуется аккуратное управление ключами

Пример итоговой структуры

{
  "payload": "SGVsbG8gd29ybGQ",
  "signatures": [
    {
      "protected": "eyJhbGciOiJSUzI1NiJ9",
      "signature": "abc123..."
    },
    {
      "protected": "eyJhbGciOiJFUzI1NiJ9",
      "signature": "xyz456..."
    }
  ]
}

Сравнение с Flattened JWS

Формат Подписей Использование
Compact 1 HTTP, Authorization
Flattened JSON 1 JSON API
General JSON несколько Multi-signature

Практические рекомендации

  • Использовать General JWS только при необходимости множественных подписей
  • Чётко определять стратегию выбора ключей
  • Логировать каждую подпись при проверке
  • Минимизировать использование незащищённых заголовков
  • Использовать crit для нестандартных параметров

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

  • Добавление подписи без пересоздания всего JWS
  • Использование внешних payload (detached payload)
  • Интеграция с JWKS (JSON Web Key Set)
  • Поддержка асинхронных резолверов ключей

Detached payload

Payload может быть исключён из JWS:

const jws = await new GeneralSign(payload)
  .addSignature(key)
  .setProtectedHeader({ alg: 'RS256' })
  .sign({ detached: true })

В этом случае payload передаётся отдельно при верификации.


Интеграция с JWKS

import { createRemoteJWKSet } from 'jose'

const JWKS = createRemoteJWKSet(new URL('https://example.com/.well-known/jwks.json'))

await generalVerify(jws, JWKS)

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


Ошибки и диагностика

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

  • JWSInvalid — некорректная структура
  • JWSSignatureVerificationFailed — подпись невалидна
  • JOSENotSupported — неподдерживаемый алгоритм

Рекомендуется обрабатывать ошибки отдельно для каждой подписи.


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

  • Каждая подпись проверяется отдельно
  • Можно оптимизировать через кеширование ключей
  • Асинхронные резолверы уменьшают блокировки

Итоговая архитектура использования

  1. Подготовка payload
  2. Добавление подписей с разными ключами
  3. Формирование General JWS
  4. Передача
  5. Верификация всех подписей
  6. Анализ результатов

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