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

Модуль auth — HMAC-аутентификация сервисов и admin Bearer guard

Модуль src/auth/* реализует два независимых механизма аутентификации входящих запросов в Payment Manager: подпись HMAC-SHA256 для service-to-service вызовов (POST /intents, POST /accounts, …) и Bearer-токен для административных операций (/admin/*). HMAC — обязательное условие для всех платёжных вызовов, включая запросы от Auth Center и nginx-прокси.

1. Назначение модуля

Payment Manager — единственный сервис с write-доступом к TigerBeetle, поэтому любой вызов, который может породить транзакцию или изменить состояние счетов, должен быть достоверно атрибутирован известному сервису. Модуль auth решает две задачи:

  • HMAC-подпись для сервис-сервис вызовов. Каждый запрос несёт идентификатор отправителя (X-Service-Id), временную метку (X-Timestamp) и подпись (X-Signature). PM проверяет, что подпись соответствует секретному ключу сервиса и что запрос свежий (replay window).
  • Bearer guard для admin endpoints. Маршруты /admin/* защищены отдельным механизмом — статическим токеном ADMIN_SECRET, передаваемым в заголовке Authorization: Bearer …. Это путь для людей-операторов и инструментов саппорта, который намеренно отделён от machine-to-machine HMAC.

Никаких «доверенных» источников без подписи не существует: даже Auth Center, который инициирует большинство платежей от имени пользователя, подписывает каждый запрос своим ключом (см. ../reference/passport/06-service-keys.md).

2. Структура файлов

src/auth/
├── hmac.ts         — низкоуровневые функции signRequest / verifyHmacRequest / sha256hex
├── hmacPlugin.ts   — Fastify-плагин, навешивающий preHandler-хук для HMAC-проверки
└── adminPlugin.ts  — Fastify-плагин для Bearer-аутентификации /admin/* роутов

hmac.ts не зависит от Fastify и переиспользуется в тестах и в утилитах для генерации подписи на стороне клиента (например, в Auth Center).

3. Ключевые типы

Модуль не экспортирует собственных TypeScript-типов: контракт описан заголовками HTTP и параметрами функций. Семантически фигурируют две сущности:

  • HmacHeaders — тройка обязательных заголовков запроса:
  • X-Service-Id: string — идентификатор сервиса-отправителя (например, auth-center, kyc-service).
  • X-Timestamp: string — UNIX-время в секундах, момент подписи.
  • X-Signature: string — hex-строка HMAC-SHA256.
  • Опционально: X-User-Id: string — UUID пользователя, от имени которого выполняется операция (передаётся Auth Center в платёжных запросах для аудита и проверки лимитов).
  • ServiceContext — расширение FastifyRequest: после успешной проверки в объект запроса проставляется поле request.serviceId, которое доступно в обработчиках для логирования и для построения traceId/контекста саги.

Расширение типа FastifyRequest (декларация request.serviceId) живёт в src/types/fastify.d.ts — см. родственный модуль shared.

4. Основные функции

sha256hex(data: string): string

Хелпер: SHA-256 от UTF-8 строки в нижнем hex. Используется и при подписи (для bodyHash), и при верификации.

Для пустого тела (GET, DELETE без payload) клиент обязан вычислить sha256hex("") — это константа:

e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Распространённая ошибка интеграции — подписывать bodyHash = "" (пустая строка) вместо хеша от пустой строки. Такая подпись не пройдёт верификацию: на стороне PM в request.rawBody ?? '' подставляется пустая строка, для которой считается тот же хеш выше. Если клиент подставил буквально пустую строку как bodyHash — мы получим разные signing-strings и 401.

signRequest(serviceSecret, timestamp, method, path, bodyHash): string

Строит каноническую строку для подписи и вычисляет HMAC-SHA256 секретом сервиса. Формат signing-string:

${timestamp}\n${METHOD}\n${PATH}\n${bodyHash}

Где: - timestamp — UNIX-секунды (целое число, не миллисекунды). - METHOD — HTTP-метод в верхнем регистре (POST, GET, PATCH, …). - PATH — полный URL-путь с querystring, как он пришёл в request.url (например, /intents/abc-123?include=fees). Не pathname без query! - bodyHashsha256hex(rawBody). Для пустого тела — sha256hex("").

Разделитель — литеральный символ \n (LF, 0x0A). Регистр заголовков и порядок не входят в строку — подписывается только сам контент.

Пример signing-string для POST /intents с пустым querystring и JSON-телом:

1717000000
POST
/intents
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08

Соответствующая подпись:

hmac-sha256(SERVICE_SECRETS["auth-center"], signing_string) → hex

Канонический payload обязательно совпадает с тем, что прочитал PM из request.rawBody байт-в-байт. Поэтому клиент должен сериализовать JSON до вычисления хеша и отправить те же самые байты в HTTP-теле без переформатирования.

verifyHmacRequest({ serviceSecret, timestamp, method, path, bodyHash, signature }): boolean

Проверяет подпись с тремя последовательными гарантиями:

  1. Парсинг timestamp. parseInt(timestamp, 10) — при NaN возвращает false.
  2. Replay window ±60 секунд. Math.abs(now - ts) > 60 ⇒ отказ. Это окно покрывает разумный clock skew между сервисами и одновременно блокирует повторное использование старых записанных запросов.
  3. timingSafeEqual. Сравнение ожидаемой и полученной подписи проводится в постоянное время через crypto.timingSafeEqual (защита от timing-атак). При несовпадении длин или нечитаемом hex возвращает false.

hmacPlugin (Fastify preHandler hook)

Плагин навешивает хук preHandler, который:

  1. Извлекает три обязательных заголовка. При отсутствии любого — 401 Unauthorized (Missing HMAC headers).
  2. Ищет секрет сервиса в config.SERVICE_SECRETS[serviceId]. Неизвестный сервис — 401 Unknown service.
  3. Берёт request.rawBody (его обязательно регистрирует fastify-raw-body глобально в server.ts с runFirst: true), вычисляет bodyHash = sha256hex(raw).
  4. Вызывает verifyHmacRequest. При отказе — 401 Invalid HMAC signature.
  5. Записывает request.serviceId = serviceId и логирует auth: HMAC verified на уровне info.

Любая ветка отказа пишет warn с контекстом (serviceId, method, url) — для отладки и для алертов SIEM.

Зависимость от fastify-raw-body. Без сырого тела HMAC-проверка невозможна: Fastify по умолчанию парсит JSON и теряет точный байт-стрим. Поэтому в server.ts обязательно регистрируется плагин с опциями runFirst: true, global: true, encoding: 'utf8'. Если это упустить — все HMAC-вызовы падают с Invalid HMAC signature даже для верно подписанных запросов.

adminPlugin (Fastify preHandler hook)

Плагин для /admin/* роутов. Логика:

  1. Извлекает Authorization: Bearer <token>. Если префикс не Bearer или токен пуст — 401 Missing Authorization: Bearer header.
  2. Сравнивает токен с config.ADMIN_SECRET через timingSafeEqual (с предварительной проверкой совпадения длин — timingSafeEqual бросает на буферах разной длины).
  3. При несовпадении — 401 Invalid admin token. При успехе — без побочных эффектов на request (admin endpoints не несут serviceId).

adminPlugin намеренно не использует HMAC: admin-операции выполняются людьми-операторами с CLI/UI, где подписание каждого запроса избыточно. Ротация ADMIN_SECRET — через рестарт PM с новым env.

В отличие от HMAC, admin-токен не имеет привязки ко времени и не защищён от replay-атаки на уровне самого протокола. Защита строится на двух предположениях: - /admin/* доступен только из внутренней сети (за nginx с allowlist по IP). - Токен длинный и хранится в секретохранилище (Vault/SOPS), а не в коде/чатах.

При утечке ADMIN_SECRET — обязательная ротация через рестарт PM. Никакого «отзыва» отдельного токена без рестарта в текущей реализации нет.

5. Жизненный цикл

Оба плагина регистрируются в области (scope) соответствующих роутов в src/server.ts, а не глобально:

  • app.register(async (scope) => { scope.register(hmacPlugin); scope.register(intentRoutes); … }) — HMAC применяется ко всем платёжным и сервисным маршрутам: POST /intents, POST /accounts, POST /policies/evaluate, POST /limits/check, …
  • app.register(async (scope) => { scope.register(adminPlugin); scope.register(adminRoutes) }, { prefix: '/admin' }) — Bearer guard действует только на /admin/*.

Public routes без аутентификации:

  • GET /health — liveness/readiness probe для Kubernetes и nginx.
  • GET /docsGET /docs/json) — Swagger UI и спецификация OpenAPI, отдаются открыто внутри кластера (наружу не публикуются через ingress).

Любые другие маршруты обязаны находиться под одним из двух плагинов — hmacPlugin для machine-to-machine, adminPlugin для admin-операций. Открыть «голый» маршрут можно только умышленно и с явным комментарием в server.ts.

6. Конфигурация

Все параметры читаются через src/shared/config.ts (модуль shared, валидируется через Zod).

Переменная Назначение Формат
SERVICE_SECRETS Словарь serviceId → secret для HMAC JSON-строка: {"auth-center":"…","kyc-service":"…"}
ADMIN_SECRET Bearer-токен для /admin/* произвольная строка ≥ 32 байт

Replay window±60 секунд (захардкожено в hmac.ts). При необходимости расширения окна в окружениях с большим clock skew — менять только в verifyHmacRequest, не выносить в env (риск ослабления безопасности на проде из-за ошибочного значения).

Ротация секретов: - HMAC-секрет сервиса меняется одновременно с обеих сторон (PM env + клиент-сервис env), желательно через blue/green деплой PM с временным расширением SERVICE_SECRETS (два ключа для одного serviceId не поддерживаются — см. «Заготовки на будущее»). - ADMIN_SECRET — рестарт PM с новым значением; активные admin-сессии не сохраняются (токен stateless).

7. Тестирование

Файл Что покрывает
test/hmac.test.ts Round-trip sign/verify, replay window (отказ при drift > 60s), пустое тело (sha256hex("")), невалидный hex, несовпадение длин подписи, mismatched secret
test/adminPlugin.test.ts Отсутствие заголовка, неверный префикс (Token … вместо Bearer …), неверный токен, успешный кейс, защита от timing-атак (длины разные ⇒ отказ без throw)

Тесты используют чистый verifyHmacRequest/signRequest без поднятия Fastify-инстанса (быстро), а плагин-тесты — fastify().inject(...) с моком config.

Полезные команды:

npm test -- test/hmac.test.ts
npm test -- test/adminPlugin.test.ts

Ручная проверка подписи через curl. Полезно для smoke-тестов в новом окружении:

BODY='{"operationType":"P2P_INTERNAL","amount":10000,"currency":"THB"}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | sha256sum | cut -d' ' -f1)
SIGN_STRING=$(printf '%s\nPOST\n/intents\n%s' "$TS" "$BODY_HASH")
SIG=$(printf '%s' "$SIGN_STRING" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)

curl -X POST https://pm.internal/intents \
  -H "X-Service-Id: auth-center" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "Content-Type: application/json" \
  --data-raw "$BODY"

Важно использовать --data-raw, а не --data — последний может перекодировать содержимое и сломать подпись.

8. Связанные модули

  • ../api/auth.md — публичный контракт HMAC-заголовков и Bearer-токена, примеры curl и кода клиента (Node.js/Dart).
  • ../reference/passport/06-service-keys.md — канонический список serviceId, владельцы, политика ротации.
  • ../reference/database/11-service-key.md — таблица pm.service_key (если используется для онлайн-управления ключами; см. также «Заготовки»).
  • ./intent.md — потребитель request.serviceId для аудита и построения traceId.
  • shared (см. src/shared/) — config.ts (env-валидация), errors.ts (UnauthorizedError), logger.ts.

9. Заготовки на будущее

Текущая реализация намеренно минимальна и решает Phase 1 задачу — подпись одним ключом на сервис. Что обсуждалось/может появиться:

  • Несколько активных ключей на сервис (key id rotation). Поддержать X-Service-Key-Id и хранилище serviceId → { keyId: secret }, чтобы катить ключи без downtime (overlap-период). Сейчас SERVICE_SECRETS — плоский словарь.
  • Онлайн-управление ключами через pm.service_key. Перенести SERVICE_SECRETS из env в БД с in-memory кэшем (обновление по NOTIFY/pub-sub). Это упростит ротацию и аудит.
  • JWT для user-context. Сейчас X-User-Id передаётся отдельным заголовком и доверенно принимается от подписанного HMAC-вызова. В будущем — подписанный JWT внутри request body или отдельный заголовок, чтобы у PM был способ независимо проверить токен.
  • Per-route HMAC-scopes. Расширить SERVICE_SECRETS до { serviceId: { secret, allowedRoutes: [...] } }, чтобы kyc-service физически не мог дернуть POST /intents. Сейчас контроль авторизации — на уровне бизнес-логики, не HMAC-плагина.
  • mTLS вместо/в дополнение к HMAC на periметре внутри кластера — обсуждалось, отложено: HMAC даёт лучшую трассировку (serviceId в логах) и проще для не-Node клиентов.

Любое из этих расширений должно начинаться с обновления ../reference/passport/06-service-keys.md и ../api/auth.md — паспорт является источником правды.