Перейти к содержимому
ecdsa.com

JWS или JWE: подписанные или зашифрованные токены JOSE

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

Выбор между JWS и JWE сводится к тому, какую защиту даёт токен JOSE. JSON Web Signature (RFC 7515) добавляет целостность и подлинность полезной нагрузке, которая остаётся читаемой; JSON Web Encryption (RFC 7516) добавляет конфиденциальность. Почти все JWT на практике — это JWS, и это удивляет тех, кто считает утверждения (claims) в них секретными.

Коротко о главном

JWSJWE
СпецификацияRFC 7515RFC 7516
Что защищаетЦелостность и источник данныхКонфиденциальность (и целостность за счёт AEAD)
Компактная формаheader.payload.signature (3 части)header.encrypted_key.iv.ciphertext.tag (5 частей)
Алгоритмы в заголовкеalg (например, ES256, RS256, HS256, Ed25519)alg для управления ключами (ECDH-ES, ECDH-ES+A256KW, RSA-OAEP-256, A256KW, dir) и enc для содержимого (A256GCM, A128CBC-HS256)
Полезная нагрузка виднаДа, она только закодирована в base64urlНет
Отправитель аутентифицированДа (подпись или MAC)Только с симметричным ключом или аутентифицированным согласованием ключей; с одним лишь ECDH-ES или RSA-OAEP — нет
Размер для набора утверждений в 70 байт (замер ниже)186 символов (ES256)405 символов (ECDH-ES+A256KW с A256GCM)
Типичное применениеJWT: токены доступа и ID-токены, DPoP, подписанные запросы, SD-JWTЗашифрованные ID-токены, токены, хранящиеся на стороне клиента, некоторые ответы FAPI и EUDI

Что защищает каждый из них

JWS подписывает base64url(header) + "." + base64url(payload). Любой, у кого есть токен, может декодировать его и прочитать утверждения; именно это делает отладчик JWT. Подпись доказывает, что токен выпустил владелец ключа и что токен не изменён, а именно это и нужно API, чтобы доверять утверждениям.

JWE генерирует случайный ключ шифрования содержимого (content encryption key, CEK), шифрует полезную нагрузку алгоритмом AEAD, указанным в enc, и доставляет CEK получателю с помощью алгоритма управления ключами, указанного в alg: обёртывает CEK с помощью RSA-OAEP, обёртывает его с помощью AES Key Wrap после согласования ключей ECDH-ES, выводит его напрямую из ECDH-ES или берёт в качестве CEK заранее распределённый общий ключ (dir). Защищённый заголовок аутентифицируется как ассоциированные данные. Прочитать полезную нагрузку может только получатель.

Важная асимметрия: если управление ключами построено на открытых ключах, любой, у кого есть открытый ключ получателя, может создать корректный JWE. Успешное расшифрование ничего не говорит об отправителе. Поэтому конфиденциальные токены, которым к тому же нужно доверять, делают вложенными: подписанный JWT (JWS) помещают внутрь JWE с cty: JWT и проверяют после расшифрования.

Размеры на практике

Скрипт ниже (запускался с jose 6.2.13 на Node.js 26.10) защищает одни и те же утверждения обоими способами.

jose: одни и те же утверждения в виде JWS и в виде JWE
import { CompactEncrypt, CompactSign, compactDecrypt, compactVerify, generateKeyPair } from "jose";

const claims = new TextEncoder().encode(JSON.stringify({ sub: "user-42", email: "alice@example.com", plan: "pro" }));

// JWS: integrity and origin; the payload is only base64url-encoded.
const signer = await generateKeyPair("ES256");
const jws = await new CompactSign(claims).setProtectedHeader({ alg: "ES256" }).sign(signer.privateKey);
await compactVerify(jws, signer.publicKey);

// JWE: confidentiality for the holder of the recipient's private key.
const recipient = await generateKeyPair("ECDH-ES+A256KW");
const jwe = await new CompactEncrypt(claims).setProtectedHeader({ alg: "ECDH-ES+A256KW", enc: "A256GCM" }).encrypt(recipient.publicKey);
const { plaintext } = await compactDecrypt(jwe, recipient.privateKey);

console.log("JWS parts:", jws.split(".").length, "length:", jws.length, "payload readable:", Buffer.from(jws.split(".")[1], "base64url").toString().includes("alice"));
console.log("JWE parts:", jwe.split(".").length, "length:", jwe.length, "decrypts:", new TextDecoder().decode(plaintext).includes("alice"));
Результат
JWS parts: 3 length: 186 payload readable: true
JWE parts: 5 length: 405 decrypts: true

Основная часть накладных расходов JWE — эфемерный открытый ключ (epk) в заголовке, обёрнутый ключ, 12-байтный IV и 16-байтный тег. Вложенный токен (сначала подписанный, затем зашифрованный) получается ещё больше, потому что весь JWS становится открытым текстом JWE.

Подводные камни

  • Утверждения JWS принимают за секрет. Не помещайте в токен, который только подписан, персональные данные, внутренние идентификаторы, которые вы не стали бы публиковать, или секреты: такой токен окажется в логах, хранилище браузера и обращениях в поддержку.
  • Доверие неподписанному JWE. Расшифрование с ECDH-ES или RSA-OAEP доказывает лишь то, что токен зашифрован для вас.
  • Слабые или устаревшие алгоритмы JWE. RSA1_5 уязвим к атакам на оракул дополнения (padding oracle), PBES2 с огромным числом итераций в p2c можно использовать для атаки на отказ в обслуживании, а реализации ECDH-ES, которые принимали точки вне кривой, в 2017 году приводили к утечке закрытых ключей (RFC 8725, § 2.5). Отдавайте предпочтение ECDH-ES+A256KW или RSA-OAEP-256 с A256GCM и ограничьте число итераций PBES2, если без него не обойтись.
  • Токен сам выбирает алгоритм. Как и в случае JWS, получателю следует принимать только те значения alg и enc, которые он ожидает для каждого ключа.
  • Сжатие. Параметр zip: DEF в JWE сжимает данные перед шифрованием, и по длине результата можно узнать кое-что об открытом тексте (RFC 8725, § 3.6); не используйте его.

Что когда выбирать

  • Токены доступа, проверяемые вашими API: JWS; оставляйте в них минимум утверждений или, если утверждения нельзя показывать, используйте непрозрачные токены с интроспекцией.
  • ID-токены с персональными данными, которые хранит браузер или мобильное приложение: обычно JWS; вложенный JWE, если ваша модель угроз включает клиентскую среду.
  • Токены, которые проходят через недоверенных посредников (перенаправления через третьи стороны, токены в URL или QR-кодах): вложенный JWS внутри JWE.
  • Сообщения между машинами по TLS со взаимной аутентификацией: часто не нужен ни тот, ни другой; TLS уже обеспечивает конфиденциальность и целостность при передаче, а JWS полезен, только если сообщение должно оставаться проверяемым после сохранения или пересылки.
  • Полезная нагрузка со сквозным шифрованием для известного получателя: JWE с ECDH-ES+A256KW и A256GCM или форматы на основе HPKE по мере их стандартизации для JOSE.

Заметки о миграции

  1. Классифицируйте утверждения в существующих токенах JWS: всё, что не должен видеть владелец, либо убирается из токена, либо переносится в JWE.
  2. Вводите вложенные токены постепенно: выдавайте JWE с cty: JWT тем клиентам, которые умеют их расшифровывать, а внутренний JWS оставляйте без изменений, чтобы использовать прежнюю логику проверки.
  3. Распространяйте ключи шифрования отдельно от ключей подписи: в записях JWKS следует указывать use: enc или use: sig и разные значения kid.
  4. Закрепите алгоритмы на обеих сторонах и отклоняйте RSA1_5, PBES2 без ограничения числа итераций и сжатие.
  5. Перепроверьте логирование: когда токены станут зашифрованными, убедитесь, что расшифрованные утверждения не записываются в логи, куда сам токен никогда не попадал. Инспектор JWE показывает заголовок и структуру JWE, не расшифровывая его.

Источники

Все сравнения