Модель безопасности
Гарантии, которые библиотека даёт на стороне проверяющего, и внутренние механизмы, которые их обеспечивают: сравнение за постоянное время, привязка алгоритма, побеждающая путаницу алгоритмов, а также преобразования сырой подписи и JWKS, выполненные на чистом PHP.
JWT подписан, а не зашифрован
Заголовок и полезная нагрузка всего лишь закодированы в base64url — любой, у кого есть токен, может их декодировать и прочитать. Подпись фиксирует подмену токена, но не делает его секретным. Два следствия, которые библиотека не может замаскировать:
- Никогда не размещайте секреты в claim’ах. Используйте claim как идентификатор и получайте чувствительные данные на стороне сервера.
- Целостность полностью зависит от того, остаётся ли ключ подписи закрытым (асимметричный) или секретным (HMAC).
Сравнение подписи за постоянное время
Для HMAC при проверке заново вычисляется MAC и сравнивается с подписью токена с помощью
hash_equals, а не ===:
// Из verify() — ветка HMAC
return hash_equals(hash_hmac($hash, $data, $keyMaterial, true), $signature);Наивное сравнение строк через === может завершиться досрочно на первом различающемся байте,
и эта крошечная разница во времени способна, за множество попыток, раскрыть корректную подпись
байт за байтом. hash_equals всегда сравнивает всю длину целиком, закрывая этот побочный канал.
RSA и ECDSA проходят через openssl_verify, который выполняет проверку по открытому ключу,
а не сравнение строк, поэтому тот же класс утечки по времени к ним неприменим.
Привязка алгоритма побеждает атаки путаницы
Каждый PublicKey создаётся с алгоритмом, и decode() отказывается продолжать, если этот
алгоритм не совпадает с alg из заголовка токена:
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 / ECDSA —
kidиз заголовка ищется в вашей карте. Отсутствующийkidвыбрасывает исключение, иkid, отсутствующий в карте, тоже выбрасывает исключение. Нет тихого отката к “какому-то другому ключу”, так что токен не может проскользнуть с неожиданным идентификатором ключа.
Преобразование формата подписи ECDSA
ECDSA — это то место, где JWT и OpenSSL расходятся в кодировании, и библиотека наводит мост
на пути проверки. Стандарт JWT (RFC 7518) передаёт подпись ECDSA как сырую конкатенацию
двух целых чисел R и S, каждое фиксированной ширины. OpenSSL же ожидает структуру
ASN.1 DER. Поэтому перед вызовом openssl_verify метод decode() переупаковывает сырую
подпись в DER:
сырая подпись = 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 кривой, а координаты дополняются до размера поля кривой.
// Упрощённая форма того, что выдаёт 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, поэтому токен действителен вплоть до, но не включая, секунду его истечения.
Связанные материалы
- «Алгоритмы» — матрица поддерживаемых алгоритмов
- «Проверка через JWKS» — проверка токенов сторонних издателей
- «Справочник API» —
JWT,JWK, объекты ключей