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

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-метод в верхнем регистре; PATHrequest.url (путь + query).
  • body — сырое тело запроса (raw body); для пустого тела хэшируется пустая строка.
  • секрет берётся по X-Service-Id из config.SERVICE_SECRETS (только из env, не хардкод).

Проверка на стороне PM (verifyHmacRequest):

  1. Парсит timestamp; неверный формат → отказ.
  2. drift = |now - timestamp|; если drift > 60 секунд → отказ (защита от replay и часового сдвига; логируется как clock skew or replay).
  3. Пересчитывает ожидаемую подпись и сравнивает через 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 > 60401.

Последствия

  • Единая граница доверия: ни один денежный вызов PM не проходит без валидной подписи; сетевой периметр не используется как замена аутентификации.
  • Replay-окно ограничено 60 секундами; для строгой идемпотентности денег работают отдельные механизмы intent (см. ADR 0004).
  • Подпись завязана на METHOD, PATH и хэш тела — нельзя переиграть запрос на другой роут или с изменённым телом, не зная секрета.
  • Требуется синхронизация часов между подписантами и PM (расхождение > 60с ломает вызовы) и аккуратная ротация секретов в env обеих сторон.
  • Подписант должен брать PATH ровно как request.url (с query), а хэшировать именно сырое тело — иначе подпись не сойдётся.

Ссылки