ADR 0002: Единый HMAC для всех вызовов в Payment Manager¶
Дата: 2026-06-06 Статус: Accepted
На какие вопросы отвечает¶
- Как один сервис аутентифицирует свои запросы к Payment Manager (PM)?
- Почему в PM нет «доверенных» вызовов без подписи (например, по IP или внутри docker-сети)?
- Что именно подписывается и по какой формуле?
- Зачем окно ±60 секунд и как оно защищает от replay?
- Откуда берётся
X-Service-Idи кто такие подписанты (Auth Center, nginx, mini-app backends)?
Контекст¶
В PM входят запросы от разных источников: Auth Center (Serverpod), хостовой
nginx-ingress (/api/pm/*), будущие mini-app backends. Все они дёргают один
платёжный endpoint POST /intents (см. ADR 0004) и
смежные роуты, влияющие на деньги в TigerBeetle (см. ADR 0001).
Альтернатива «доверенных» вызовов (по приватной сети, IP-allowlist, заголовку без подписи) была отвергнута: docker-сеть и внутренний периметр не являются границей доверия для денежных операций, а IP легко подделать внутри хоста. Решение: единый механизм аутентификации для абсолютно всех входящих вызовов PM — HMAC-SHA256 по запросу. Никаких исключений.
Решение¶
Каждый запрос несёт три заголовка:
| Заголовок | Назначение |
|---|---|
X-Service-Id |
идентификатор сервиса-отправителя (ключ в SERVICE_SECRETS) |
X-Timestamp |
unix-время отправки (секунды) |
X-Signature |
HMAC-SHA256 в hex |
Подпись считается над строкой из 4 строк, разделённых \n
(источник: projects/payment-manager/src/auth/hmac.ts):
signingString = `${timestamp}\n${METHOD}\n${PATH}\n${sha256hex(body)}`
signature = HMAC_SHA256(serviceSecret, signingString) // hex
METHOD— HTTP-метод в верхнем регистре;PATH—request.url(путь + query).body— сырое тело запроса (raw body); для пустого тела хэшируется пустая строка.- секрет берётся по
X-Service-Idизconfig.SERVICE_SECRETS(только из env, не хардкод).
Проверка на стороне PM (verifyHmacRequest):
- Парсит
timestamp; неверный формат → отказ. drift = |now - timestamp|; еслиdrift > 60секунд → отказ (защита от replay и часового сдвига; логируется какclock skew or replay).- Пересчитывает ожидаемую подпись и сравнивает через
timingSafeEqual(constant-time, без утечки по времени). Длины буферов не совпали → отказ.
Историческая формула
{ServiceId}:{Timestamp}:{bodyhash}— НЕВЕРНА и в коде не используется. Каноничен только вариант выше с разделителями\n.
Где применяется¶
HMAC навешивается через hmacPlugin (preHandler-хук Fastify) на scope
денежных роутов — он НЕ глобальный, а регистрируется внутри scope нужных групп
маршрутов. При отсутствии заголовков, неизвестном X-Service-Id или неверной
подписи возвращается 401 Unauthorized. После успеха request.serviceId
доступен обработчику. Источник: projects/payment-manager/src/auth/hmacPlugin.ts.
Покрытые роуты (HMAC): POST /intents, /intents/quote,
/intents/:id/confirm, /intents/:id/cancel, POST /accounts,
/accounts/register-ipps, GET /accounts/{name}/balance,
/accounts/{name}/transactions, POST /policies/evaluate и admin-роуты
POST /admin/fee-rules/dry-run, POST /admin/intents/:id/resolve,
POST /admin/debug-level.
/health(health-проверки) зарегистрирован вне HMAC-scope (app.register(healthRoutes)до scope сhmacPluginвserver.ts) и подписи НЕ требует.
Подписанты¶
X-Service-Id соответствует записи в pm.service_key / SERVICE_SECRETS.
Известные подписанты: nginx-gateway (хостовой ingress для /api/pm/*),
Auth Center, mini-app backends. Каждый владеет своим секретом.
flowchart LR
AC[Auth Center] -->|X-Service-Id + sig| PM[Payment Manager]
NG[nginx-gateway] -->|X-Service-Id + sig| PM
MA[mini-app backend] -->|X-Service-Id + sig| PM
PM -->|verifyHmacRequest: drift<=60s + timingSafeEqual| OK{валидна?}
OK -->|да| H[обработчик: request.serviceId]
OK -->|нет| E[401 Unauthorized]
Пример¶
POST /intents, тело {"operationType":"P2P_TRANSFER",...}, timestamp=1749200000:
sha256hex(body) = 9f86d0... (hex от сырого тела)
signingString = "1749200000\nPOST\n/intents\n9f86d0..."
X-Signature = HMAC_SHA256(secret_nginx-gateway, signingString) // hex
X-Service-Id: nginx-gateway
X-Timestamp: 1749200000
Если этот же запрос переиграть через 70 секунд — drift > 60 → 401.
Последствия¶
- Единая граница доверия: ни один денежный вызов PM не проходит без валидной подписи; сетевой периметр не используется как замена аутентификации.
- Replay-окно ограничено 60 секундами; для строгой идемпотентности денег работают отдельные механизмы intent (см. ADR 0004).
- Подпись завязана на
METHOD,PATHи хэш тела — нельзя переиграть запрос на другой роут или с изменённым телом, не зная секрета. - Требуется синхронизация часов между подписантами и PM (расхождение > 60с ломает вызовы) и аккуратная ротация секретов в env обеих сторон.
- Подписант должен брать
PATHровно какrequest.url(с query), а хэшировать именно сырое тело — иначе подпись не сойдётся.
Ссылки¶
- Реализация:
projects/payment-manager/src/auth/hmac.ts,projects/payment-manager/src/auth/hmacPlugin.ts - Dev-документация: ../dev/05-security-and-auth.md
- PM-доки: auth.md, nginx.md
- Смежные ADR: 0001 TigerBeetle, 0003 разделение схем, 0004 единый Intent API