Пакет · jwt · глубокое погружение

Модель безопасности

Гарантии, которые библиотека даёт на стороне проверяющего, и внутренние механизмы, которые их обеспечивают: сравнение за постоянное время, привязка алгоритма, побеждающая путаницу алгоритмов, а также преобразования сырой подписи и JWKS, выполненные на чистом PHP.

JWT подписан, а не зашифрован

Заголовок и полезная нагрузка всего лишь закодированы в base64url — любой, у кого есть токен, может их декодировать и прочитать. Подпись фиксирует подмену токена, но не делает его секретным. Два следствия, которые библиотека не может замаскировать:

  • Никогда не размещайте секреты в claim’ах. Используйте claim как идентификатор и получайте чувствительные данные на стороне сервера.
  • Целостность полностью зависит от того, остаётся ли ключ подписи закрытым (асимметричный) или секретным (HMAC).

Сравнение подписи за постоянное время

Для HMAC при проверке заново вычисляется MAC и сравнивается с подписью токена с помощью hash_equals, а не ===:

php
// Из verify() — ветка HMAC
return hash_equals(hash_hmac($hash, $data, $keyMaterial, true), $signature);

Наивное сравнение строк через === может завершиться досрочно на первом различающемся байте, и эта крошечная разница во времени способна, за множество попыток, раскрыть корректную подпись байт за байтом. hash_equals всегда сравнивает всю длину целиком, закрывая этот побочный канал. RSA и ECDSA проходят через openssl_verify, который выполняет проверку по открытому ключу, а не сравнение строк, поэтому тот же класс утечки по времени к ним неприменим.

Привязка алгоритма побеждает атаки путаницы

Каждый PublicKey создаётся с алгоритмом, и decode() отказывается продолжать, если этот алгоритм не совпадает с alg из заголовка токена:

php
if ($publicKey->getAlgorithm() !== $algorithm) {
  throw new JWTException("The key's algorithm ... does not match the token's ...");
}

Это блокирует классическую атаку «путаница алгоритмов». Открытый ключ RSA по своей природе является публичным. Без привязки атакующий мог бы взять ваш открытый ключ RS256, сформировать токен с alg: HS256 и подписать его, используя байты открытого ключа в качестве секрета HMAC — и проверяющий, слепо доверяющий alg из заголовка, принял бы такой токен. Поскольку здесь ключ привязан к RS256, токен, заявляющий HS256, никогда не доходит до проверки.

alg: none тоже отвергается

Токен без alg или с alg: none разрешается в строку none, которой нет в таблице поддерживаемых алгоритмов — поэтому decode() выбрасывает исключение “Algorithm ‘none’ is not supported.” Печально известный обход через “none” здесь попросту не имеет ни одного пути выполнения.

Выбор ключа явный

То, как выбирается ключ для проверки, зависит от семейства алгоритмов, и оба пути реализуют отказ в доступе по умолчанию (fail closed):

  • HMAC — используется первый ключ в списке. Пустой список выбрасывает “No secret key was provided for HMAC algorithm.”
  • RSA / ECDSAkid из заголовка ищется в вашей карте. Отсутствующий kid выбрасывает исключение, и kid, отсутствующий в карте, тоже выбрасывает исключение. Нет тихого отката к “какому-то другому ключу”, так что токен не может проскользнуть с неожиданным идентификатором ключа.

Преобразование формата подписи ECDSA

ECDSA — это то место, где JWT и OpenSSL расходятся в кодировании, и библиотека наводит мост на пути проверки. Стандарт JWT (RFC 7518) передаёт подпись ECDSA как сырую конкатенацию двух целых чисел R и S, каждое фиксированной ширины. OpenSSL же ожидает структуру ASN.1 DER. Поэтому перед вызовом openssl_verify метод decode() переупаковывает сырую подпись в DER:

text
сырая подпись  =  R (n байт) ‖ S (n байт)

           signatureToDER()

DER  =  30 <len>  02 <len> R   02 <len> S
      SEQUENCE  INTEGER      INTEGER

Преобразование также учитывает правило знакового целого ASN.1: если старший бит R или S установлен, добавляется ведущий байт 0x00, чтобы значение не было прочитано как отрицательное. Именно это позволяет библиотеке проверять соответствующие спецификации токены ECDSA от любого издателя, следующего стандартам.

JWKS: PEM, собранный вручную

Парсер JWK превращает набор ключей провайдера в готовые к использованию PublicKey без какой-либо библиотеки ASN.1. Для ключа RSA он берёт n (модуль) и e (экспоненту) в base64url и собирает полную структуру SubjectPublicKeyInfo в DER — OID идентификатора алгоритма, битовую строку, последовательность RSAPublicKey — затем оборачивает её в base64, получая PEM для openssl_pkey_get_public. Ключи EC строятся тем же способом из crv, x и y: выбирается OID кривой, а координаты дополняются до размера поля кривой.

php
// Упрощённая форма того, что выдаёт createPemFromRsa()
"-----BEGIN PUBLIC KEY-----\n" . base64(SubjectPublicKeyInfo) . "-----END PUBLIC KEY-----\n"

Поскольку всё это стандартный DER, получаемые ключи неотличимы от загруженных с диска — именно поэтому распарсенные ключи JWKS работают прямо в decode().

Ошибки разбора проглатываются намеренно

parseKeySet() перехватывает исключения по каждому ключу и пропускает проблемный ключ, так что одна некорректная запись в документе провайдера не ломает остальные. Компромисс: ключ, который вы ожидаете, может незаметно исчезнуть, всплыв позже как “Key with ‘kid’=… was not found” уже на этапе декодирования, а не на этапе разбора.

Что библиотека не проверяет

Проверка доказывает подлинность и свежесть — но не авторизацию и не намерение:

  • iss / aud не валидируются. Проверяйте их сами для токенов, которые вы не выпускали (см. «Проверка через JWKS»).
  • Только exp, nbf, iat среди временных claim’ов проверяются, каждый расширяется на величину leeway. exp сравнивается как (now - leeway) >= exp, поэтому токен действителен вплоть до, но не включая, секунду его истечения.

Связанные материалы