Перейти к основному содержимому

ADR 0031 — Собственная аутентификация: argon2id, EdDSA и ротация refresh

Контекст​

Brabo аутентифицирует через Keycloak с Фазы 1: на один контейнер больше в compose, на один StatefulSet больше в Kubernetes, realm в JSON, который нужно поддерживать, и сетевая зависимость на пути каждого запроса. Взамен используется небольшое подмножество того, что он предлагает, — вход по паролю, один публичный клиент для web и две служебные учётные записи для внутреннего трафика. Никакого MFA, никакой федерации, никакого социального входа.

Баланс не сходится. То, что Keycloak действительно решает в этой системе, помещается в один модуль домена api; то, что он берёт взамен, — постоянная эксплуатация. FASE 7a меняет обе стороны: аутентификация в домене, Keycloak удалён.

Эта поставка (7.1) строит модуль параллельно, не трогая guard. Глобальный JwtAuthGuard по-прежнему проверяет токен Keycloak, а RBAC Фазы 1 не затрагивается. Причина в том, что guard — это APP_GUARD, и вся остальная api зависит от request.user, который он заполняет: построить новую аутентификацию и сменить эмитента в одной поставке значило бы не оставить ни одного тестируемого промежуточного состояния. С новыми маршрутами в @Public() модуль можно проверять от начала до конца, пока текущая система продолжает работать.

Решение​

Пароль: argon2id с параметрами, зафиксированными в коде​

m = 19456 KiB (19 MiB), t = 2, p = 1, вывод 32 байта — второй профиль, рекомендованный OWASP. Выполняется за ~50 мс и укладывается в лимит памяти контейнера даже при параллельных проверках.

Параметры находятся в константе, а не в переменной окружения. Изменение стоимости хэша — не настройка tuning: оно требует плана перехэширования накопленного. Будучи выставленным как env, это стало бы рычагом, который кто-то опустит в production, чтобы «улучшить задержку входа», и никто не заметит, что защита упала.

Библиотека: @node-rs/argon2, выбранная вместо argon2, потому что поставляет предкомпилированный бинарник — Dockerfile.prod многостадийный на Alpine (ADR 0024), а альтернатива потребовала бы тулчейн компиляции в builder.

Access token: EdDSA (Ed25519), 15 минут, производный ключ​

Альтернативой был HS256 с секретом в env. EdDSA выбран потому, что публичный ключ можно опубликовать в /.well-known/jwks.json и проверять его любым сервисом, не получая секрет, который подписывает, — с HMAC тот, кто проверяет, тоже может подделать.

Ключ не генерируется, а выводится: seed в 32 байта получается из scryptSync(AUTH_JWT_SECRET, 'brabo-auth-jwt-seed', 32), того же формата, который EnvelopeEncryptionService уже использует для мастер-ключа учётных данных (Фаза 1). Это решает три проблемы сразу — ни один приватный ключ не коммитится, пара идентична между репликами и между перезапусками (generateKeyPairSync при загрузке дал бы ключ на процесс, и симптомом был бы прерывистый вход за балансировщиком нагрузки), а ротация копирует уже существующий шаблон, а не изобретает второй.

kid — это отпечаток RFC 7638 самой JWK: он выводится из ключа, поэтому нет переменной, которая могла бы рассинхронизироваться с тем, что он называет.

Ротация через AUTH_JWT_SECRET_PREVIOUS, принимаемый только при проверке и публикуемый в JWKS, никогда не используемый для подписи, — та же конструкция, что у CREDENTIALS_MASTER_KEY_PREVIOUS, с тем же громким предупреждением при загрузке, напоминающим, что ротацию нужно завершить.

Refresh: непрозрачный токен в 256 бит, хэш HMAC-SHA256, обязательная ротация​

Токен — это 32 байта randomBytes в base64url. База хранит hmac-sha256(pepper, token).

Это не argon2, и выбор, противоположный выбору для пароля, намеренный. Argon2 существует, чтобы удорожить словарную атаку против секрета с низкой энтропией; против 256 бит CSPRNG словаря нет, поэтому стоимость купила бы ноль бит. Хуже: у argon2 есть соль на запись, из-за чего хэш перестал бы быть функцией только токена — и where token_hash = $1 стал бы невозможен, превращая каждый refresh в полный просмотр таблицы. Pepper (вместо чистого SHA-256) бесплатен и делает дамп базы без окружения процесса бесполезным.

Каждый refresh потребляет предъявленный токен (rotated_at) и выпускает потомка с тем же family_id и тем же family_started_at. Предъявление уже ротированного токена — подпись кражи: отзывается всё семейство и записывается событие безопасности.

family_started_at — абсолютный потолок сессии. Без него ротация каждые 15 минут даёт вечную сессию — и никто этого не замечает, пока аудит не спросит, сколько может жить сессия.

rotated_at и revoked_at — ортогональные колонки. Их слияние уничтожило бы различие между «вы предъявили уже потраченный токен» (сигнал кражи → каскад) и «вы предъявили токен, который убил каскад другого» (жертва ниже по течению → без новой тревоги). Без него каждая вкладка легитимного пользователя порождала бы обнаружение кражи во время инцидента, наполняя журнал безопасности шумом именно тогда, когда он должен быть читаем.

Блокировка: скользящее окно в собственной таблице, ключ по e-mail​

auth_lockout_hits, структурно идентичная rate_limit_hits (ADR 0027): INSERT и COUNT в одном выражении через CTE, без Redis.

Отдельная от auth_events таблица, а не лишняя колонка в ней. Журнал append-only по правилу CLAUDE.md, а обнуление счётчика при успешном входе требует DELETE. В одной таблице пришлось бы изобрести водяной знак («сбои с последнего успеха»), что навсегда привязало бы план запроса throttle к набору индексов аудита. Раздельно каждая получает подходящее ей правило — и сроки хранения тоже противоположны: счётчик становится обузой PII через час, журнал должен выжить.

Ключ корзины — нормализованный e-mail (в HMAC), а не id пользователя. С id корзина существовала бы только после нахождения учётной записи: попытка против несуществующего e-mail не учитывалась бы и не блокировалась, а сама блокировка стала бы оракулом существования. С e-mail реальная и воображаемая учётные записи ведут себя одинаково по построению.

Блокировка отсекает состояние до попытки. Отсечение после сделало бы порог на единицу меньше: при пороге 5 пятая попытка была бы отклонена даже с правильным паролем.

У корзин e-mail и IP разные лестницы (5:30,8:300,12:900 и 20:30,30:120). Единый порог ошибался бы с обеих сторон: 5 на IP уронило бы любой офис за NAT, 20 на учётную запись было бы слишком щедро для атаки на пароль. Потолок IP короткий, потому что там побочный ущерб достаётся тому, кто ничего не делал.

Потолок лестницы e-mail равен окну по построению. Больший потолок потребовал бы постоянного locked_until, с очередью разблокировки и admin-endpoint — скользящее окно не представляет блокировку длиннее самого себя.

Перечисление e-mail: один инвариант, а не список случаев​

Любой ответ, отличный от единообразного отказа, может быть достигнут только после успешной проверки пароля.

Правило само решает все случаи, включая те, что обычно ускользают: несуществующий e-mail, неверный пароль, заблокированная учётная запись, отключённая учётная запись и — самый коварный — пользователь, импортированный из Keycloak, у которого ещё нет пароля. Ответ «задайте пароль» последнему подтвердил бы, что адрес существует, и что это унаследованная учётная запись, — самый ценный сигнал перечисления в системе.

Конкретные последствия: поиск учётных данных и verify argon2 выполняются всегда, в том числе без учётной записи (против dummy-хэша с идентичными параметрами) и в том числе при уже заблокированной корзине. Проверка блокировки по e-mail идёт после verify. Выйти раньше — инстинкт любого ревьюера, и это ровно утечка: дешёвая ветвь отвечает за ~1 мс против ~50 мс у дорогой.

Единственный ранний выход — у корзины IP, по противоположной причине: там ничего не скрывается (история принадлежит самому запрашивающему), а запуск argon2 означал бы отдать то исчерпание CPU, для предотвращения которого существует корзина.

Регистрация возвращает 202 и для уже зарегистрированного e-mail, с той же стоимостью argon2, и уведомляет владельца адреса. 409 Conflict — то, чего потребовал бы здравый смысл REST, — отдал бы список пользователей тому, у кого есть wordlist, и сделал бы бесполезной всю аккуратность входа.

Токены учётной записи: одна таблица, потребление условным UPDATE​

account_tokens с enum назначения (email_verification, password_reset, set_initial_password), а не три таблицы: механика идентична, меняются данные. Три таблицы означали бы три копии атомарного UPDATE потребления, а это единственное здесь, в чём нельзя ошибиться дважды. Риск перепутать назначение закрыт на входе — один метод на назначение, ни один вызывающий не передаёт значение.

Потребление — один условный UPDATE с returning; UPDATE и есть охрана. Прочитать-а-потом-записать пропускает два одновременных запроса, и это не гипотеза: сканер безопасности корпоративной почты открывает каждую ссылку каждого сообщения, поэтому робот обычно потребляет токен раньше, чем человек кликнет. Гонка — нормальный случай.

Сброс отзывает все семейства пользователя — противоположность каскаду при повторном использовании, и разница в модели угрозы: там доказательство указывает на одно семейство; здесь пользователь сказал «кажется, в мою учётную запись вошли». И он не выпускает сессию: вход напрямую по ссылке из e-mail сделал бы компрометацию e-mail равной захвату учётной записи, без второго шага.

Последствия​

Повторное использование refresh разлогинивает и легитимного пользователя. На стороне сервера двойная отправка и replay вора побайтово идентичны — тот же токен, тот же маршрут, часто тот же IP. Без сигнала для разделения безопасная политика — предполагать кражу. Исправление живёт в клиенте: refresh в режиме single-flight, одно обещание в полёте, разделяемое всеми вызывающими. Это требование 7.2, а не деталь реализации — без него два вызова, одновременно получившие 401, разлогинят пользователя.

Web не может сказать «этот e-mail уже используется». Форма начинает говорить «если адрес свободен, мы отправили письмо с подтверждением». Это продуктовая цена, принятая в обмен на закрытие перечисления.

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

Access token не отзывается. Это 15 минут JWT без состояния, поэтому существует окно до 15 минут, в течение которого украденный токен продолжает действовать после сброса пароля. Это цена того, чтобы не обращаться к базе на каждом запросе, и именно поэтому TTL короткий. Claim версии учётных данных решил бы это, и он записан в backlog — стоит переоценить в 7.2, потому что текущий JwtAuthGuard уже делает одно обращение к базе на запрос (syncUser), что делает предельную стоимость намного ниже обычной.

Постоянное время не утверждается. Тесты детерминированно доказывают, что ни одна ветвь не пропускает дорогую работу и ни одна не даёт различимого ответа: шпион в PasswordHasher проверяет, что три ветви сбоя входа вызывают verify ровно один раз, с хэшами идентичных параметров. Тест по часам в общем CI хрупок, а тест, который становится красным один раз из двадцати прогонов, хуже отсутствия теста — команда учится нажимать «re-run». Оставшиеся различия (лишняя строка лога, INSERT известной ветви в запросе сброса) на порядки меньше сетевого джиттера и зафиксированы здесь как принятые.

Ротация AUTH_TOKEN_PEPPER разлогинивает всех и делает недействительными открытые токены учётных записей. У pepper нет _PREVIOUS: принимать двойную проверку на каждом refresh, навсегда, ради сценария, который случается раз в никогда, не окупается. Это зафиксировано в runbook.

Публичная поверхность выросла с четырёх маршрутов до двенадцати. Каждый обоснован в docs/security-surface.md, а route-surface.spec.ts перечисляет все двенадцать дословно — открытие ещё одного по-прежнему требует трогать тест. Стоит зафиксировать, что это открывает: RateLimitGuard пропускает маршрут @Public(), поэтому ни один из маршрутов аутентификации им не покрыт. Эту поверхность держит прогрессивная блокировка, и только она.

Осознанный backlog​

Вне объёма по решению, а не по забывчивости:

  • MFA (TOTP, WebAuthn), социальный вход и федерация OIDC. Keycloak их предлагал, и они не использовались; реализовать их сейчас значило бы платить цену без спроса.
  • Api как провайдер OIDC. Она аутентифицирует собственных пользователей, а не является эмитентом для третьих сторон.
  • Словарь утёкших паролей (HIBP, rockyou). Короткий список в домене ловит очевидное и не выдаёт себя за большее, чем есть.
  • Оппортунистическое перехэширование при изменении параметров argon2.
  • Терпимый replay refresh в пределах льготного окна, чтобы смягчить двойную отправку. У этого реальная цена безопасности — вор, повторивший в пределах окна, получает действительную пару без тревоги, — и поэтому это входит только после single-flight на клиенте, если ещё понадобится.
  • Очистка таблиц аутентификации (auth_lockout_hits, refresh_tokens, account_tokens). У rate limit очистка уже есть; эти должны войти в тот же механизм.

Чего ещё не хватает в FASE 7a​

Этот ADR покрывает 7.1. Остаются открытыми: смена эмитента в JwtAuthGuard (7.2), сбор OpenAPI в docs/reference/api/ (7.3), service token между engine и api, импорт пользователей Keycloak и удаление Keycloak из compose, манифестов и web. ADR 0027 классифицировал HTTP-поверхность, а ADR 0024 и 0025 спроектировали образ и деплой — на все три нужно будет сослаться, а не редактировать их, когда Keycloak уйдёт.