Перейти к содержанию
FoxAuth

Блог

От чего защищает DPoP, а от чего нет

Утёкший токен доступа работает у любого, кто его нашёл. DPoP привязывает токен к ключу клиента. Что это даёт, чего стоит и когда я бы не стал возиться.

Автор: FoxAuth ·

Токен нашёлся в отчёте о падении приложения.

Я отвечал за API, и к нему подключали мобильное приложение (в терминах OAuth — клиент). Когда в приложении случалась ошибка, которую оно не обрабатывало, его HTTP-библиотека упаковывала упавший запрос и отправляла во внешний сервис сбора отчётов о падениях. Вместе с заголовками. Так в панели этого сервиса оказался заголовок Authorization: Bearer eyJhbGci.... Токен действовал ещё четырнадцать минут. Прочитать его мог любой дежурный из поддержки этого сервиса. Насколько я знаю, никто им не воспользовался. Но если бы кто-то воспользовался, протокол бы ему никак не помешал.

Это не ошибка в чьём-то коде. Так устроен bearer-токен («токен на предъявителя»). В RFC 6750 он так и определён: пользоваться им может любой, у кого он есть. Сервер ресурсов — это API, который принимает токен. Он проверяет подпись токена, срок действия, аудиторию (для какого API выпущен токен) и scope (что токен разрешает). Но он никогда не задаёт вопрос, который был важен в тот день: тот ли это клиент, которому выдали токен? Он и не может его задать. В запросе нет ничего, по чему это можно проверить.

DPoP (RFC 9449) добавляет в запрос то, по чему это можно проверить. Хочу точно разобрать, что DPoP даёт, а что нет. Я видел, как его продают под лозунгом «останавливает кражу токенов». На деле он делает вещь поуже — и полезнее.

Что клиент подписывает в каждом запросе и почему украденный токен без ключа бесполезен

Ещё до того, как просить токен, клиент создаёт пару ключей: открытый и закрытый. Закрытый ключ он никому не отдаёт. Дальше к каждому запросу с токеном клиент прикладывает небольшой подписанный JWT. Он называется доказательством (proof). В заголовке доказательства лежит открытый ключ. В payload (теле JWT) описан запрос, к которому это доказательство относится. Вот доказательство для вызова эндпоинта userinfo в раскодированном виде:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA" }
}
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "GET",
"htu": "https://auth.example.com/userinfo",
"iat": 1758196200,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo",
"nonce": "eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o"
}

Поля JWT называются claim. Каждый claim в доказательстве закрывает одну лазейку. Без него прошёл бы определённый повтор (replay) — это когда атакующий ещё раз отправляет то, что перехватил. Схема ниже показывает, какой claim какую лазейку закрывает. Следите за стрелками. Каждая идёт от доказательства к тому, что сервер может проверить сам, не полагаясь на клиента.

К чему привязан каждый claim доказательства DPoP: открытый ключ — к cnf.jkt токена, ath — к токену, htm и htu — к запросу, jti — к хранилищу повторов, nonce — к собственному окну сервераДоказательство DPoP (JWT)header.jwk — открытый ключath — SHA-256 токена доступаhtm, htu — метод и URLjti — уникальный IDnonce — выдан серверомiat — время подписиподписано закрытым ключом клиентаТокен доступаcnf.jkt = thumbprint(jwk)Этот запрос: GET /userinfo на этом хостеХранилище jti: был ли такой за 5 мин?Окно nonce самого сервера, без храненияотпечаток RFC 7638хеш должен совпастьвыдан недавно?iat: в пределах ±300 с от времени сервера, если нет nonce

На выделенной цветом стрелке держится вся схема. Это и есть привязка к отправителю (sender-constraint): токен привязан к ключу, и тот, кто отправляет токен, должен доказать, что этот ключ у него есть. Внутри токена доступа лежит claim cnf.jkt (cnf — от confirmation, «подтверждение»). В нём записан отпечаток (thumbprint) открытого ключа, которым подписано доказательство. Отпечаток — это короткий хеш ключа, посчитанный по RFC 7638. Зачем он нужен? Сервер ресурсов мог ни разу не общаться с клиентом. Но по одному токену он всё равно узнаёт, к какому ключу привязан токен. А потом проверяет, что доказательство подписано именно этим ключом.

Остальные claim делают вот что. ath — хеш самого токена доступа. Он не даёт снять доказательство с одного токена и прицепить к другому, привязанному к тому же ключу. htm и htu — метод и URL запроса. Они не дают взять доказательство для одного эндпоинта и повторить его на другом. jti — уникальный идентификатор доказательства. По нему сервер замечает, что одно и то же доказательство пришло дважды. nonce и iat отвечают за свежесть — то есть за то, насколько недавно сделано доказательство. Им отведён отдельный раздел: именно с ними больше всего хлопот, когда DPoP работает в продакшене.

Вот как всё это выглядит в настоящем HTTP-трафике. Начнём с обмена кода: клиент меняет код авторизации на токены. По сравнению с обычным обменом с PKCE нового здесь только две вещи: заголовок DPoP в запросе и поле token_type в ответе:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3a
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCIsImtpZCI6IjIwMjYtMDkifQ...",
"token_type": "DPoP",
"expires_in": 900,
"scope": "openid orders:read",
"refresh_token": "Ql3oQ4fq8NWY0m9tHqqvqA1H7ArTn0O1x1PbUdECfxa"
}

token_type: DPoP говорит клиенту, какую схему теперь писать в заголовке Authorization. Клиент отправляет Authorization: DPoP <token>, а не Bearer. Если сервер получит привязанный к DPoP токен со схемой Bearer, он должен его отклонить. Почему? Раз клиент пишет Bearer, он не знает, что токен привязан к ключу. Значит, и доказательство он не пришлёт.

Где хранится сама привязка, зависит от вида токена. Opaque-токен — это случайная строка без содержимого. Что за ней стоит, знает только сервер авторизации. Чтобы проверить такой токен, сервер ресурсов спрашивает о нём сервер авторизации (такой запрос называется интроспекцией). Привязка приходит в ответе, в поле cnf. А JWT несёт своё содержимое в себе. Если токен доступа — JWT, как здесь, то же поле cnf лежит прямо в его payload:

{
"iss": "https://auth.example.com",
"sub": "248289761001",
"aud": "https://api.example.com",
"client_id": "spa-7f3a",
"exp": 1758197100,
"scope": "openid orders:read",
"cnf": { "jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I" }
}

Итак, вся последовательность — два шага на сервере авторизации, потом блок проверок на сервере ресурсов. Самое важное — этот блок проверок, шаг 4. Его стоит прочитать внимательно.

Последовательность: привязанный к DPoP токен выдаётся на token endpoint, затем предъявляется серверу ресурсов, который проверяет токен, подпись доказательства, отпечаток ключа, метод и URL, хеш токена и уникальность доказательстваКлиент (ключ K)Сервер авторизацииСервер ресурсов1 POST /token grant_type=authorization_codeDPoP: proof(htm POST, htu /token, jwk = pub(K))2 200 token_type: DPoPaccess_token с cnf.jkt = thumbprint(pub(K))3 GET /ordersAuthorization: DPoP <access_token>DPoP: proof(htm GET, htu /orders, ath = sha256(token), jti, jwk = pub(K))4 проверяет всё или ничегоподпись токена, exp, aud, scope — как раньшеподпись доказательства верна по header.jwkthumbprint(header.jwk) = cnf.jkt токенаhtm/htu — этот запрос, ath — этот токенjti не встречался, iat или nonce свежие5 200 OK

Теперь прогоним через эту схему тот самый отчёт о падении. У инженера поддержки есть токен доступа. Но у него нет закрытого ключа K, поэтому подписать доказательство он не может. Доказательство, подписанное любым другим ключом, не пройдёт шаг 4 — сверку отпечатка. Сам по себе токен ничего не стоит.

А теперь допустим, что он перехватил весь запрос — токен вместе с доказательством. Такое доказательство годится только для одного метода, одного URL и одного токена. Свежим оно остаётся либо пять минут (по iat), либо пока сервер принимает его nonce. А если сервер ресурсов ведёт хранилище повторов (помнит jti, которые уже принял), доказательство сработает ровно один раз. Был пароль, который работает четырнадцать минут. Стал один-единственный запрос, который атакующий, может быть, сумеет повторить, а может, и нет.

От чего DPoP не защищает: от чужого кода там, где хранится ключ

Это настоящее улучшение. Но другого DPoP не даёт. Он защищает, когда токен утёк уже после выдачи и где-то вне клиента. Например, в отчёте о падении, в логе прокси, в заголовке Referer, через расширение браузера, которое читает хранилище, или через неправильно настроенный кеш. Во всех этих случаях у атакующего есть токен, но нет ключа.

Если код атакующего выполняется там же, где хранится ключ, DPoP бессилен. Возьмём XSS (межсайтовый скриптинг) — когда чужой скрипт выполняется прямо на вашей странице. Пусть одностраничное приложение (веб-приложение, которое целиком работает в браузере) хранит ключ в IndexedDB — базе данных внутри браузера. Чужому скрипту не нужно ничего красть и выносить наружу. Он подписывает доказательства настоящим ключом. Вызывает API от имени настоящего пользователя, с настоящей страницы. И каждая проверка на шаге 4 проходит — как и должна. Привязать токен к ключу, которым может пользоваться и атакующий, — значит ничего не защитить.

Я не раз слышал на дизайн-ревью такой обмен репликами: «Что будет, если фронтенд взломают?» — «Мы используем DPoP». Это вообще не ответ на этот вопрос. DPoP мешает повторить украденный токен где-то в другом месте. Но он не поможет, если взломано само место, где хранится ключ.

Скучные базовые меры DPoP тоже не заменяет. Возьмём привязанный к DPoP токен, который живёт четыре часа и не ограничен аудиторией. Тот, кто взломает клиент, получит токен, который четыре часа работает с любым API. Ущерб ограничивают другие вещи: короткий срок жизни токенов, ограничение аудитории и ротация refresh-токенов. Refresh-токен живёт дольше токена доступа, и по нему клиент получает новые токены доступа. Ротация — это когда при каждом его использовании выдаётся новый refresh-токен. DPoP работает вместе с этими мерами, а не вместо них.

Зачем сервер выдаёт nonce: часы клиента могут врать

Без nonce о свежести доказательства говорит только iat — время, когда его подписали. Значит, всё зависит от того, совпадают ли часы клиента с часами сервера. Наш сервер принимает iat, если оно расходится с его временем не больше чем на 300 секунд. Размер этого окна RFC сознательно оставляет на усмотрение сервера. Теперь возьмём телефон, у которого часы ушли на шесть минут. Каждый его запрос получает ошибку invalid_dpop_proof. И ничто в этой ошибке не говорит «у вас неправильные часы». Она говорит только, что доказательство плохое. Кто-то потратит на это полдня. Этим кем-то бывал и я.

Серверный nonce (RFC 9449, §8 и §9) снимает зависимость от часов клиента: опорой становятся часы сервера. Nonce — это значение, которое выдаёт сервер. Клиенту не нужно понимать, что внутри. Он просто кладёт это значение в поле nonce следующего доказательства. Сервер принимает доказательство, если недавно сам выдавал этот nonce. Теперь свежесть обеспечивает nonce, а iat нужен только для справки. Платить за это приходится первым запросом. При первом обращении у клиента ещё нет nonce. Поэтому первый запрос не проходит — так задумано. И в самом отказе должно быть всё, что нужно клиенту, чтобы исправиться:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="nonce is required in the DPoP proof", algs="ES256 PS256"
DPoP-Nonce: eyJ7S_zG8SxF0SbEjb-5aYW7Xbt9Jd_kKdIaI4BbX2o

Клиент читает заголовок DPoP-Nonce, подписывает с этим nonce новое доказательство и повторяет запрос. В любом следующем ответе может прийти свежий DPoP-Nonce, и клиент подставит его в следующий запрос. Поэтому клиент, который постоянно отправляет запросы, больше никогда не увидит 401. А клиент, который несколько минут простаивал, увидит его ещё раз. Этот круг показан на схеме ниже. Клиентская библиотека должна делать его обычным циклом, а не обрабатывать как ошибку.

Круг nonce в DPoP: первый запрос без nonce отклоняется с use_dpop_nonce и заголовком DPoP-Nonce, повтор несёт этот nonce и проходит, а успешный ответ передаёт следующий nonceКлиентСервер ресурсов1 GET /orders DPoP: proof(без nonce)401 WWW-Authenticate: DPoP error=“use_dpop_nonce”DPoP-Nonce: n12 GET /orders DPoP: proof(nonce = n1)200 OKDPoP-Nonce: n2 — уходит в следующее доказательство; пока клиент активен, второго 401 нетНа эндпоинте /token тот же отказ — это 400 с ошибкой в теле, а не 401.

Две вещи в этом обмене отняли у нас много времени, и криптография тут ни при чём. Первая — код статуса. Возьмём token endpoint — эндпоинт /token, где сервер авторизации выдаёт токены. О плохом доказательстве там, по RFC 9449, сообщают ответом 400 с ошибкой в теле, как о любой другой ошибке этого эндпоинта. А сервер ресурсов сообщает о том же ответом 401 с ошибкой в заголовке WWW-Authenticate, как в RFC 6750. Ошибка одна, статусов два, и какой из них правильный, зависит от роли эндпоинта. У нашего сервера есть два своих эндпоинта, которые принимают токены доступа (защищённые ресурсы). Сначала каждый из них сам исправлял статус, в своём блоке catch. Потом мы перенесли это решение в общий обработчик ошибок: теперь он выбирает статус по маршруту. С тех пор третий защищённый ресурс без этого правила уже не добавишь.

Вторая — CORS. Это правила, по которым браузер решает, что скрипт может прочитать в ответе с другого origin (другой схемы, хоста или порта). Заголовки WWW-Authenticate и DPoP-Nonce из такого ответа браузер скрипту не покажет, если сервер не перечислил их в Access-Control-Expose-Headers. Без них одностраничное приложение видит ответ 401, но не видит, что в нём. И повторить запрос не сможет никогда. Спецификация упоминает это мимоходом. А ведь именно от этого зависит, заработает ли DPoP в браузере вообще.

За nonce прячется вопрос проектирования. По-моему, он интереснее, чем выглядит в спецификации: откуда сервер знает, какие nonce он недавно выдавал? Очевидный ответ — хранить их. Это плохой ответ. Nonce уходит с каждым ответом. Значит, каждому вызову API пришлось бы сначала что-то записать в хранилище и только потом вернуть результат. Поэтому мы nonce не храним, а вычисляем. Nonce для каждой минуты получается через HKDF — стандартную функцию, которая выводит новое значение из секрета. На вход ей идут секрет сервера и номер минуты в роли соли (дополнительного входного значения). Доказательство принимается, если его nonce совпадает с nonce любой из пяти минут вокруг текущей:

const STEP = 60;
function nonceFor(secret: Uint8Array, step: number): string {
const salt = new Uint8Array(8);
new DataView(salt.buffer).setBigUint64(0, BigInt(step));
return base64url(hkdfSync('sha256', secret, salt, '', 32));
}
export function nextNonce(secret: Uint8Array): string {
return nonceFor(secret, Math.floor(Date.now() / 1000 / STEP) + 1);
}
export function acceptsNonce(secret: Uint8Array, nonce: string): boolean {
const now = Math.floor(Date.now() / 1000 / STEP);
let matched = 0;
for (const offset of [-2, -1, 0, 1, 2]) {
matched |= Number(constantTimeEqual(nonceFor(secret, now + offset), nonce));
}
return matched === 1;
}

В этом коде три сознательных решения. Первое: сервер выдаёт nonce следующей минуты, а не текущей. Даже если клиент получил его в конце минуты, у него остаётся три-четыре минуты, пока nonce не выпадет из окна. Второе: acceptsNonce сравнивает nonce со всеми пятью кандидатами и объединяет результаты. На первом совпадении код не останавливается. Поэтому по времени проверки нельзя понять, какая минута совпала. Третье: ничего не хранится. Значит, любая реплика (копия сервера) с тем же секретом принимает nonce, выданный другой репликой. А перезапуск ничего не забывает — забывать нечего.

Платить за это приходится отзывом. Вычисленный nonce нельзя отозвать досрочно, и он не привязан к конкретному клиенту. Это нормально. Nonce показывает только, что доказательство свежее. Одноразовым он доказательство не делает. Одноразовость обеспечивает jti. Люди путают эти две вещи — и начинают верить, что nonce защищает от повторов. Не защищает.

Ловить повторы — значит обращаться к базе на каждом запросе

Чтобы ловить повторы по jti, сервер должен помнить идентификатор каждого принятого доказательства, для каждого клиента. Помнить его нужно столько, сколько доказательство ещё может быть принято: пять минут, а если время в доказательстве опережает часы сервера — до десяти. Получается чтение и затем запись на каждом вызове API с токеном. Допустим, ваш сервер ресурсов — автомасштабируемая функция без собственного состояния. Тогда это хранилище — новая зависимость, которой до DPoP не было. Обычно её впервые замечают, когда тревогу начинает поднимать именно хранилище повторов.

Хочется отключить обнаружение повторов. Тут я хочу честно рассказать, что мы решили. В нашем сервере есть переключатель, который его отключает. Он задаётся в конфигурации, которую сервер читает при запуске. Ни в консоли администратора, ни в API управления его нет — намеренно. Он нужен для двух случаев. Первый — набор тестов на соответствие стандарту, который нарочно повторяет одно и то же доказательство. Второй — человек, который разбирается с проблемой и повторяет запрос. Ни то ни другое не администрирование.

Представьте переключатель, который выключает защиту, а discovery (документ, где сервер перечисляет свои возможности) продолжает её рекламировать. Именно такие переключатели щёлкают в два часа ночи во время инцидента и забывают вернуть обратно. А в два часа ночи люди работают как раз в консоли. Поэтому там его нет.

Без обнаружения повторов вы теряете гораздо меньше, чем «DPoP перестаёт работать». Пусть атакующий перехватил запрос целиком — токен и доказательство. Он может повторять ровно этот запрос, с тем же методом и URL, пока nonce или iat доказательства не устареют. Пять минут повторять один GET /orders — реальная утечка, но не катастрофа. Пять минут повторять один POST /transfers — совсем другой разговор. Допустим, ваш API идемпотентен (повтор запроса ничего не меняет) и в основном отдаёт данные. Тогда вы вполне можете решить, что хранилище повторов того не стоит. Я бы не делал так по умолчанию. И не дал бы переключать это туда-обратно с веб-страницы.

Главная польза DPoP — защита refresh-токена, а не токена доступа

В спецификации есть пункт, который легко пропустить. Некоторые клиенты публичные: они не могут доказать token endpoint, кто они. У публичного клиента к ключу привязывается и refresh-токен. У конфиденциального клиента, который доказать это может, — нет. Его refresh-токен и так привязан к учётным данным клиента, а их сервер проверяет при каждом использовании refresh-токена. В нашей реализации это одно условие: если метод аутентификации клиента — none, refresh-токен получает тот же jkt, что и токен доступа.

По-моему, это самый сильный аргумент за DPoP в браузере. Сильнее всего, что касается токена доступа. В одностраничном приложении токен доступа живёт пятнадцать минут, а refresh-токен — днями. И хранится он в браузере. Именно там его скорее всего и найдёт атакующий: в утёкшей резервной копии профиля браузера или на общем компьютере. До DPoP украденный из браузера refresh-токен открывал доступ к аккаунту на всё время, пока ротация давала ему жить.

Теперь добавим DPoP и неизвлекаемый ключ WebCrypto: страница может им пользоваться, но скопировать его из браузера нельзя. Тогда украденный refresh-токен — просто строка. Обменять её на новые токены нельзя: для обмена нужно доказательство, подписанное ключом, а ключ не покидал origin. Ротация по-прежнему важна. Но теперь она ловит атакующего, которого уже остановили, а не того, кто уже внутри.

Та же идея работает и на шаг раньше — через dpop_jkt. Это параметр запроса авторизации. Он привязывает код авторизации к ключу ещё до того, как код выдан. Тогда код, перехваченный между редиректом и обменом на токен, нельзя обменять с другим ключом. Правда, от большей части этого и так защищает PKCE. А наш сервер заполняет dpop_jkt сам, когда доказательство приходит вместе с pushed authorization request (PAR — клиент заранее отправляет параметры авторизации прямо на сервер). Так что на практике это ничего не стоит. Это не повод включать DPoP. Но и не повод его выключать.

Когда DPoP стоит своей цены, а когда нет

Платить приходится за три вещи, и подпись — не из их числа. Первое: клиенту нужен ключ, место, где его хранить, и доказательство на каждый запрос. В браузере ключ должен быть неизвлекаемым CryptoKey в IndexedDB, в нативном приложении — лежать в хранилище ключей платформы. Если ключ можно извлечь, вы незаметно теряете большую часть того, за что заплатили. Второе: клиент должен считать use_dpop_nonce поводом повторить запрос, а не сбоем. А сервер должен открыть два заголовка для вызовов с другого origin, иначе повтор не случится никогда. Третье: кто-то должен отвечать за хранилище повторов, где записи живут пять минут. Или за решение его не заводить.

Что вы получаете взамен. DPoP окупается в трёх случаях. Токены идут через инфраструктуру, которую вы не контролируете. Публичный клиент держит долгоживущий refresh-токен. Или один утёкший токен может натворить столько бед, что фразу «четырнадцать минут для любого, кто его найдёт» нельзя произнести вслух на ревью.

Теперь обратный случай: ваш собственный конфиденциальный клиент, который работает на сервере. Он ходит в свой же API по TLS, токены живут пять минут, аудитория узкая. Тут я бы не стал возиться. Токены, привязанные через mTLS (TLS, где сертификат предъявляет и клиент), или просто короткоживущие bearer-токены дают то же самое, а кода на клиенте нужно меньше.

Чего я не знаю — так это что браузеры сделают дальше. Есть Device Bound Session Credentials (DBSC): сначала это было предложение Chrome, теперь это черновик IETF. DBSC пытается дать cookie то же свойство — привязку к ключу. Ключ там держит сам браузер, а не страница. Это как раз та граница, которую DPoP пересечь не может. Если DBSC приживётся и научится работать с API-токенами, большую часть сегодняшнего клиентского кода DPoP возьмёт на себя платформа. Если же DBSC останется функцией только для cookie, DPoP так и будет единственной привязкой к отправителю, доступной браузерному клиенту без клиентского сертификата. Я бы строил в расчёте на второй вариант. А первому бы порадовался.

Хотите посмотреть, как это устроено в работающем сервере? В FoxAuth DPoP сначала включается для всего сервера, а затем для каждого клиента — атрибутом клиента dpop_bound_access_tokens. Требовать ли nonce — это настройка. А переключатель повторов — тот самый, о котором шла речь выше; в консоли его нет намеренно. Справочник настроек перечисляет параметры, справочник эндпоинтов показывает, какие эндпоинты принимают доказательство, а модель угроз фиксирует, что должна остановить каждая проверка.