Блог
От чего защищает 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 какую лазейку закрывает. Следите за стрелками. Каждая идёт от доказательства к тому, что сервер может проверить сам, не полагаясь на клиента.
На выделенной цветом стрелке держится вся схема. Это и есть привязка к отправителю
(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.1Host: auth.example.comContent-Type: application/x-www-form-urlencodedDPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIi...
grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=spa-7f3aHTTP/1.1 200 OKContent-Type: application/jsonCache-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. Его стоит прочитать внимательно.
Теперь прогоним через эту схему тот самый отчёт о падении. У инженера поддержки есть токен доступа. Но у него нет закрытого ключа 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 UnauthorizedWWW-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.
А клиент, который несколько минут простаивал, увидит его ещё раз. Этот круг показан на схеме ниже.
Клиентская библиотека должна делать его обычным циклом, а не обрабатывать как ошибку.
Две вещи в этом обмене отняли у нас много времени, и криптография тут ни при чём. Первая — код
статуса. Возьмём 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 — это настройка. А переключатель повторов — тот самый, о котором шла речь выше; в консоли его нет
намеренно. Справочник настроек перечисляет параметры,
справочник эндпоинтов показывает, какие эндпоинты принимают
доказательство, а модель угроз фиксирует, что должна остановить
каждая проверка.