Модуль 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("") — это константа:
Распространённая ошибка интеграции — подписывать bodyHash = "" (пустая строка) вместо хеша от пустой строки. Такая подпись не пройдёт верификацию: на стороне PM в request.rawBody ?? '' подставляется пустая строка, для которой считается тот же хеш выше. Если клиент подставил буквально пустую строку как bodyHash — мы получим разные signing-strings и 401.
signRequest(serviceSecret, timestamp, method, path, bodyHash): string¶
Строит каноническую строку для подписи и вычисляет HMAC-SHA256 секретом сервиса. Формат signing-string:
Где:
- timestamp — UNIX-секунды (целое число, не миллисекунды).
- METHOD — HTTP-метод в верхнем регистре (POST, GET, PATCH, …).
- PATH — полный URL-путь с querystring, как он пришёл в request.url (например, /intents/abc-123?include=fees). Не pathname без query!
- bodyHash — sha256hex(rawBody). Для пустого тела — sha256hex("").
Разделитель — литеральный символ \n (LF, 0x0A). Регистр заголовков и порядок не входят в строку — подписывается только сам контент.
Пример signing-string для POST /intents с пустым querystring и JSON-телом:
Соответствующая подпись:
Канонический payload обязательно совпадает с тем, что прочитал PM из request.rawBody байт-в-байт. Поэтому клиент должен сериализовать JSON до вычисления хеша и отправить те же самые байты в HTTP-теле без переформатирования.
verifyHmacRequest({ serviceSecret, timestamp, method, path, bodyHash, signature }): boolean¶
Проверяет подпись с тремя последовательными гарантиями:
- Парсинг timestamp.
parseInt(timestamp, 10)— при NaN возвращаетfalse. - Replay window ±60 секунд.
Math.abs(now - ts) > 60⇒ отказ. Это окно покрывает разумный clock skew между сервисами и одновременно блокирует повторное использование старых записанных запросов. - timingSafeEqual. Сравнение ожидаемой и полученной подписи проводится в постоянное время через
crypto.timingSafeEqual(защита от timing-атак). При несовпадении длин или нечитаемом hex возвращаетfalse.
hmacPlugin (Fastify preHandler hook)¶
Плагин навешивает хук preHandler, который:
- Извлекает три обязательных заголовка. При отсутствии любого —
401 Unauthorized(Missing HMAC headers). - Ищет секрет сервиса в
config.SERVICE_SECRETS[serviceId]. Неизвестный сервис —401 Unknown service. - Берёт
request.rawBody(его обязательно регистрируетfastify-raw-bodyглобально вserver.tsсrunFirst: true), вычисляетbodyHash = sha256hex(raw). - Вызывает
verifyHmacRequest. При отказе —401 Invalid HMAC signature. - Записывает
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/* роутов. Логика:
- Извлекает
Authorization: Bearer <token>. Если префикс неBearerили токен пуст —401 Missing Authorization: Bearer header. - Сравнивает токен с
config.ADMIN_SECRETчерезtimingSafeEqual(с предварительной проверкой совпадения длин —timingSafeEqualбросает на буферах разной длины). - При несовпадении —
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 /docs(иGET /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.
Полезные команды:
Ручная проверка подписи через 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 — паспорт является источником правды.