Модель безопасности
Гарантии, которые библиотека даёт на стороне проверяющего, и внутренние механизмы, которые их обеспечивают: сравнение за постоянное время, привязка алгоритма, побеждающая путаницу алгоритмов, а также преобразования сырой подписи и 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):
kidв заголовке есть — берётся ключ с этим идентификатором. Если такого нет, выбрасывается исключение: никакого отката на другой ключ, поэтому токен не может повлиять на то, чем его проверят.kidнет, ключ один — берётся он.kidнеобязателен по RFC 7515 §4.1.4, и многие эмитенты его не ставят.kidнет, ключей несколько — исключение. Взять первый попавшийся значило бы поставить результат проверки в зависимость от порядка элементов массива.- Ключей не передано вовсе — исключение “No key was provided for verification.”
Преобразование формата подписи ECDSA
ECDSA — это то место, где JWT и OpenSSL расходятся в кодировании, и библиотека наводит мост
в обе стороны. Стандарт JWT (RFC 7518 §3.4) передаёт подпись ECDSA как сырую
конкатенацию двух целых чисел R и S, каждое дополнено до ширины кривой. OpenSSL же и
выдаёт, и ожидает структуру ASN.1 DER. Поэтому encode() разворачивает то, что вернул
openssl_sign(), а decode() заворачивает обратно то, что пришло в токене:
encode: openssl_sign() → DER → signatureFromDER() → R ‖ S → токен
decode: токен → R ‖ S → signatureToDER() → DER → openssl_verify()
R ‖ S = R (n байт) ‖ S (n байт) n = 32 / 48 / 66
DER = 30 <len> 02 <len> R 02 <len> S
SEQUENCE INTEGER INTEGERПреобразование учитывает обе особенности DER, и обе односторонние:
- знак — если старший бит
RилиSустановлен, добавляется ведущий байт0x00, чтобы значение не прочиталось как отрицательное; - минимальность — лишние ведущие нули, наоборот, убираются. Сырая форма дополнена нулями слева по определению, и для P-521 старший байт почти всегда нулевой: 521 бит не заполняют 66 октетов. Оставить его значило бы отдать невалидный DER.
Плюс длина: короткая форма DER заканчивается на 127 байтах, а SEQUENCE для ES512 длиннее,
и ему нужна длинная форма.
Именно это позволяет библиотеке и принимать токены 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, объекты ключей