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

Мгновенный отзыв: ревокация в stateless-мире

Как отзывать подписанные токены, когда серверных сессий нет: гранулярность отзыва, fail-open vs fail-closed, TTL записей и почему stateless ≠ безотзывность.

M

Me2eM Organization

5 мин. чтения

Stateless-сессии имеют репутацию неотзывных. Логика кажется железной: если сервер не хранит сессии — он не может их отозвать. Подписанный токен валиден до истечения TTL, и никакая сила не изменит это, пока подпись верна.

Логика правильная — и неполная. Stateless означает «состояние не обязательно». Это не запрет состояния — это свобода выбрать, какое состояние вы готовы нести ради какого свойства. Отзыв — ровно тот случай, когда немного состояния покупает мгновенную силу.

В Me2eM это оформлено в интерфейс из одного метода:

interface RevocationChecker {
  isRevoked(jti: string): Promise<boolean>;
}

Один метод. Но за ним — целая дисциплина: что отзывать, где хранить, что делать при сбое хранилища, и почему правильный ответ на «сколько живёт запись отзыва» отличается от «сколько живёт токен».

Что такое jti — ключ ко всему

Каждый токен несёт jti (JWT ID — термин унаследован из JWT, работает так же) — уникальный идентификатор, сгенерированный при создании сессии:

import { Session } from '@me2em/core';

const session = await Session.create(handle, {
  audience: 'app.com',
  scopes: ['read'],
  ttl: 3600,
});
// jti генерируется автоматически (UUID) и хранится в подписанном payload —
// верификатор извлекает его из токена и передаёт в RevocationChecker

jti — это имя сессии в мире отзыва. Оно не выводится из ключа, не зависит от хэндла, не повторяется. Именно поэтому отзыв точечный: отозван может быть один конкретный токен, не затрагивая другие сессии того же хэндла.

А в Mode 2 (верификация по аттестационной цепочке) jti есть не только у сессий, но и у аттестаций — и это меняет масштаб отзыва радикально. Но об этом ниже.

Два уровня отзыва: сессия vs ветка

Что отзываемКакЭффектГранулярность
Session jtiisRevoked(session.jti)Одна сессия перестаёт приниматься. Хэндл продолжает работать — новые сессии создаются свободноТочечный
Attestation jtiisRevoked(attestation.jti)Вся ветка: все текущие и все будущие сессии под этой аттестацией отклоняютсяМассовый, иерархический

Разница видна на живом примере. Сотрудник уволен:

// Mode 1: точечный отзыв — нужно знать jti конкретной сессии
await revocationService.revokeSession(activeSessionJti);
// ← отзывает ОДНУ сессию. Новые создаются свободно. Неполно!

// Mode 2: ветвевой отзыв — отзываем аттестацию сотрудника
await revocationService.revokeAttestation(workerAttestationJti);
// ← отзывает ВСЁ: текущие сессии, будущие сессии, на всех аудиториях

В Mode 2 верификатор проверяет jti на каждом уровне цепочки — от корневой аттестации вниз. Отзыв любой аттестации делает всю цепь недействительной. Отозвали аттестацию отдела — умер весь отдел. Отозвали аттестацию сотрудника — умер сотрудник. Это иерархический kill switch, встроенный в протокол.

Реализация: Redis, 15 строк

Минимальный production-совместимый бэкенд:

import Redis from 'ioredis';
import { RevocationChecker } from '@me2em/core';

export class RedisRevocationService implements RevocationChecker {
  private readonly redis: Redis;
  private readonly REVOKED_KEY = 'me2em:revoked';

  constructor(redisUrl: string) {
    this.redis = new Redis(redisUrl);
  }

  /** Вызывается верификатором для каждого уровня (аттестации, сессии). */
  async isRevoked(jti: string): Promise<boolean> {
    return (await this.redis.sismember(this.REVOKED_KEY, jti)) === 1;
  }

  /** Отзыв одной сессии. */
  async revokeSession(jti: string): Promise<void> {
    await this.redis.sadd(this.REVOKED_KEY, jti);
  }

  /** Отзыв всей ветки через аттестацию. */
  async revokeAttestation(attestationJti: string): Promise<void> {
    await this.redis.sadd(this.REVOKED_KEY, attestationJti);
  }
}

Характеристики — порядок величины (зависит от окружения):

МетрикаЗначение
ПроверкаO(1), SISMEMBER, ~0.1–0.2 мс
Память~36 байт на UUID, 1M отзывов < 50 МБ
Проверок на запроспо числу уровней цепочки (1–3)
Latency добавка0.3–0.6 мс на запрос с полной цепочкой

Где проверять: порядок имеет значение

Позиция проверки ревокации в цепочке верификации — не перестановка, а решение с последствиями:

Типовой порядок проверок верификатора:
1. Формат и обязательные поля
2. Аудитория
3. Время (exp, iat)
4. Структура цепочки
5. Подпись аттестации
6. Время аттестации
7. Ревокация аттестации   ← здесь
8. ... остальные уровни ...
N. Ревокация сессии       ← последняя

Логика: невалидная подпись → запрос даже не доходит до хранилища отзыва. Атакующий не может использовать ваш Redis-кластер как oracle для проверки подделок — подделки отбраковываются раньше. Ревокация — последняя, потому что она самая дорогая (сетевой вызов) и имеет смысл только для криптографически валидных токенов.

Fail-open или fail-closed: вопрос на миллион

Что делает система, когда хранилище отзыва недоступно? Redis упал, сеть моргнула, таймаут истёк?

// Fail-open: недоступность хранилища НЕ блокирует пользователя
async isRevoked(jti: string): Promise<boolean> {
  try {
    return await this.redis.sismember(...) === 1;
  } catch {
    return false;   // ← ошибка = «не отозван» = пустить
  }
}

// Fail-closed: недоступность хранилища блокирует всех
async isRevoked(jti: string): Promise<boolean> {
  try {
    return await this.redis.sismember(...) === 1;
  } catch {
    return true;    // ← ошибка = «считать отозванным» = отказать
  }
}
Fail-openFail-closed
Redis падаетпользователи работают без проверки отзывавсе пользователи заблокированы
Рискотозванный токен проскочит в окно недоступностиполный простой системы
Кому подходитмессенджеры, соцсети, контентные платформыбанки, military, системы с ценой пропуска > ценой простоя

Me2eM оставляет выбор приложению — протокол фиксирует только интерфейс. Но документируйте выбор явно: fail-open, при котором отозванный токен может проскочить, и fail-closed с тотальным простоем — это разные катастрофы, и команда должна знать, какая из них выбрана.

TTL записей отзыва: не вечные надгробия

Сессия истекает сама через TTL. Но запись о её отзыве в SET — не истекает никогда. Без чистки SET растёт бесконечно.

Два подхода:

Подход 1: cron-чистка. Храним jti + exp вместе (Hash вместо SET), периодически выметаем просроченные:

// Запись с встроенным exp:
await redis.hset('me2em:revoked:map', jti, String(exp));
// Cron раз в час:
const all = await redis.hgetall('me2em:revoked:map');
const now = Math.floor(Date.now() / 1000);
for (const [jti, exp] of Object.entries(all)) {
  if (Number(exp) < now) await redis.hdel('me2em:revoked:map', jti);
}

Подход 2: per-key EXPIRE. Каждый отзыв — отдельный ключ с TTL:

// Вместо SET — отдельные ключи:
await redis.set(`me2em:rev:${jti}`, '1', 'EX', ttlSeconds);
// Проверка — EXISTS вместо SISMEMBER:
const revoked = (await redis.exists(`me2em:rev:${jti}`)) === 1;

Per-key EXPIRE чище (автоочистка встроена в Redis), но количество ключей больше. Для большинства масштабов — оба подхода работают; выбор — вкусовщина.

Ключевое правило: TTL записи отзыва = exp токена + clock skew. Запись живёт ровно столько, сколько мог бы жить токен. После истечения токена отзыв не нужен — токен мёртв сам по себе.

Namespaces: одна ловушка, которую нужно знать

Аттестации и сессии делят одно хранилище. jti аттестации и jti сессии — оба UUID. Теоретически возможно столкновение: UUID аттестации случайно совпал с UUID сессии, и отзыв сессии убил аттестацию.

Вероятность ничтожна (UUID 122 бит случайности), но дисциплина требует явности. Два подхода:

// Конвенция — префиксы в ключах:
await redis.sadd('me2em:rev:att', attJti);   // аттестации
await redis.sadd('me2em:rev:sess', sessJti); // сессии

// Или два checker'а с разными префиксами:
class AttestationRevocationChecker implements RevocationChecker { ... }
class SessionRevocationChecker implements RevocationChecker { ... }

Me2eM не навязывает — приложение выбирает. Но выбирайте осознанно и документируйте.

Проверка на каждом уровне цепочки

В Mode 2 ревокация проверяется на каждом уровне аттестационной цепочки, а не только на сессии:

flowchart TD
    A["Root Attestation<br/>jti: root-uuid"] --> B["Handle Attestation<br/>jti: handle-uuid"] --> S["Session Token<br/>jti: session-uuid"]
    A -.->|"отзыв уровня ROOT"| K1["REVOKED / ROOT"]
    B -.->|"отзыв уровня<br/>HANDLE_ATTESTATION"| K2["REVOKED /<br/>HANDLE_ATTESTATION"]
    S -.->|"отзыв уровня SESSION"| K3["REVOKED / SESSION"]

Каждый уровень проверяется независимо. Отзыв любого уровня делает всю цепь недействительной:

ОтозваноЧто умираетКод / уровень ошибки
Root attestationВся веткаREVOKED / ROOT
Handle attestationХэндл + все его сессииREVOKED / HANDLE_ATTESTATION
SessionОдна сессияREVOKED / SESSION

Это даёт гранулярность отзыва, которую Signal-подобные системы не имеют: их identity key долгоживущий, а отзыв ограничен отвязкой устройств целиком.

Итог

СвойствоКак достигается
Stateless-сессииСостояние опционально: только для отзыва
Мгновенный отзывRevocationChecker — один метод, O(1)
ГранулярностьТочечный (session jti) + иерархический (attestation jti)
Защита от oracle-атакРевокация после проверки подписи
Выбор политикиFail-open / fail-closed — приложение решает
ГигиенаTTL записи = TTL токена + skew

Отзыв в stateless-мире — не компромисс, а дизайн-решение. Вы выбираете, сколько состояния готовы нести, и получаете ровно ту гранулярность отзыва, за которую платите состоянием.

Что читать дальше

Поделиться:

Похожие статьи

Stateless-бэкенд для Me2eM: верификация сессий без базы данных

Серверная сторона протокола: NestJS Guard для verifyAttested, инвентаризация минимального состояния — публичные корни, ревокации, admin-маппинг — маршрутизация ошибок и горизонтальное масштабирование без sticky-сессий.

M Me2eM Organization
6 мин. чтения
#sessions #attestations #revocation

Продажа доступа: подписанная квитанция как товар в Me2eM

Сценарий Mode 2 в чистом виде: дрон продаёт 30 минут съёмки, спутник — окно tasking'а. Покупатель верифицирует покупку публичным корнем, оператор не ведёт учёт, чек не подделать и не продлить.

M Me2eM Organization
6 мин. чтения
#attestations #iot #security

Готовы начать?

Установите пакет, пройдите quick start и за считанные минуты запустите свой первый сценарий.