DPoP токены: структура и верификация

DPoP (Demonstration of Proof-of-Possession) представляет собой механизм криптографической привязки HTTP-запроса к конкретной паре ключей клиента. Основная цель — устранить возможность использования украденного access token без владения приватным ключом, который использовался при его получении.

В контексте JavaScript-библиотеки Jose реализация DPoP строится вокруг JWS (JSON Web Signature), где каждый запрос сопровождается отдельным подписанным JWT, называемым DPoP proof.


Формат DPoP proof JWT

DPoP proof — это JWT, подписанный приватным ключом клиента. Он передаётся в HTTP-заголовке:

DPoP: eyJhbGciOiJFZERTQSIsInR5cCI6ImRwbCtqd3QifQ...

JWT состоит из трёх частей:

  • Header (заголовок)
  • Payload (полезная нагрузка)
  • Signature (подпись)

Заголовок DPoP JWT

В библиотеке Jose заголовок формируется с указанием алгоритма и типа токена:

  • typ: всегда dpop+jwt
  • alg: алгоритм подписи (чаще всего ES256, RS256, EdDSA)
  • jwk: публичный ключ клиента в формате JWK

Пример:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  }
}

Ключевой момент: сервер использует jwk для привязки access token к конкретному ключу клиента.


Полезная нагрузка DPoP proof

Payload содержит обязательные поля, которые обеспечивают защиту от повторного использования и подделки запроса:

  • htu — HTTP URI запроса
  • htm — HTTP метод (GET, POST и т.д.)
  • iat — время выпуска токена (issued at)
  • jti — уникальный идентификатор токена (для защиты от replay-атак)

Пример payload:

{
  "htu": "https://api.example.com/resource",
  "htm": "POST",
  "iat": 1710000000,
  "jti": "b7f1c2c3-8d2a-4d5e-9f1c-1a2b3c4d5e6f"
}

Криптографическая подпись

Подпись формируется приватным ключом клиента. В библиотеке Jose это реализуется через SignJWT.

Пример генерации DPoP proof:

import { generateKeyPair, SignJWT, exportJWK } from 'jose'

const { privateKey, publicKey } = await generateKeyPair('ES256')

const jwk = await exportJWK(publicKey)

const dpop = await new SignJWT({
  htu: 'https://api.example.com/resource',
  htm: 'POST',
  iat: Math.floor(Date.now() / 1000),
  jti: crypto.randomUUID()
})
  .setProtectedHeader({
    typ: 'dpop+jwt',
    alg: 'ES256',
    jwk
  })
  .sign(privateKey)

Привязка access token к DPoP

После успешной авторизации сервер возвращает access token, содержащий подтверждение привязки ключа:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "DPoP",
  "cnf": {
    "jkt": "sha256-thumbprint-of-jwk"
  }
}

Поле cnf.jkt — это SHA-256 thumbprint публичного ключа, использованного при создании DPoP proof.


Верификация DPoP proof в Jose

1. Проверка структуры JWT

Первый этап — декодирование и проверка формата:

import { jwtVerify, importJWK } from 'jose'

Проверяется:

  • наличие typ = dpop+jwt
  • корректность алгоритма
  • наличие jwk в header

2. Извлечение публичного ключа

const publicKey = await importJWK(dpopHeader.jwk, 'ES256')

Этот ключ используется для проверки подписи.


3. Проверка подписи

await jwtVerify(dpop, publicKey)

Если подпись не совпадает — запрос отклоняется.


Проверка payload параметров

После криптографической верификации выполняется логическая проверка:

HTTP метод

htm === req.method

Несовпадение означает попытку подмены запроса.


HTTP URI

htu === request.url

Проверяется полное совпадение URI, включая схему и путь.


Временная метка

Math.abs(now - iat) < allowedSkew

Обычно допускается небольшое окно (например, 5 минут).


Replay защита через jti

jti должен быть уникальным. Сервер хранит уже использованные значения:

if (usedJtiSet.has(payload.jti)) {
  throw new Error('Replay attack detected')
}

Связь DPoP и access token

После проверки DPoP proof сервер связывает его с access token через thumbprint:

import { calculateJwkThumbprint } from 'jose'

const thumbprint = await calculateJwkThumbprint(publicKey)

Сравнение:

thumbprint === access_token.cnf.jkt

Если значение не совпадает, токен считается украденным или поддельным.


Проверка DPoP в HTTP-запросе

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

  1. Извлечь заголовок DPoP
  2. Распарсить JWT
  3. Проверить подпись через публичный ключ
  4. Сравнить htu и htm
  5. Проверить iat
  6. Проверить уникальность jti
  7. Сопоставить jkt с access token
  8. Принять или отклонить запрос

Использование Jose для middleware проверки

Пример middleware:

import { jwtVerify, importJWK, calculateJwkThumbprint } from 'jose'

export async function verifyDpop(req) {
  const dpop = req.headers['dpop']
  const token = req.headers['authorization']?.replace('Bearer ', '')

  const { payload, protectedHeader } = await jwtVerify(dpop, async (header) => {
    return await importJWK(header.jwk, header.alg)
  })

  if (payload.htu !== req.url) {
    throw new Error('Invalid htu')
  }

  if (payload.htm !== req.method) {
    throw new Error('Invalid htm')
  }

  const thumbprint = await calculateJwkThumbprint(protectedHeader.jwk)

  const accessPayload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString())

  if (accessPayload.cnf?.jkt !== thumbprint) {
    throw new Error('Key mismatch')
  }

  return true
}

Особенности безопасности реализации

DPoP добавляет несколько уровней защиты:

  • невозможность повторного использования access token без ключа
  • защита от MITM при утечке токена
  • привязка к HTTP контексту запроса
  • контроль времени и уникальности каждого запроса

Типичные ошибки при реализации

  • отсутствие проверки htu (открывает возможность replay на другом endpoint)
  • игнорирование jti
  • слабое окно iat, позволяющее replay-атаки
  • хранение ключей без rotation
  • неверная проверка cnf.jkt

Поведение библиотеки Jose при ошибках

При использовании jwtVerify возможны исключения:

  • JWSSignatureVerificationFailed
  • JWTExpired
  • JOSEError

Каждое из них требует отдельной обработки на уровне middleware, иначе система остаётся уязвимой к частичным обходам проверки.


Ключевые аспекты архитектуры DPoP в приложениях

  • каждый запрос является криптографически подписанным объектом
  • ключ клиента становится обязательным фактором доступа
  • access token без ключа теряет ценность
  • сервер становится проверяющей стороной без хранения секретов клиента

Практическая модель взаимодействия

  1. Клиент генерирует ключевую пару
  2. Отправляет DPoP proof при авторизации
  3. Получает access token с cnf.jkt
  4. Подписывает каждый API запрос новым DPoP proof
  5. Сервер проверяет соответствие токена и ключа

Интеграция с современными API архитектурами

DPoP особенно актуален в:

  • OAuth 2.1
  • SPA (Single Page Applications)
  • мобильных клиентах
  • публичных API с высоким риском утечек токенов

В связке с Jose он обеспечивает строгую криптографическую модель доверия без хранения сессионных секретов на сервере.