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

Аутентификация HTTP API

Payment Manager — внутренний сервис. Все «боевые» endpoints защищены HMAC-SHA256 поверх X-Service-Id / X-Timestamp / X-Signature. Публичны только GET /health и GET /docs. Отдельный Bearer-guard через adminPlugin существует в коде, но в server.ts фактически не подключён — см. раздел «Открытые вопросы».

1. Схемы аутентификации

Endpoint(ы) Схема Кто валидирует
GET /health, GET /docs/* без auth
POST /intents, POST /intents/:id/confirm, POST /intents/:id/cancel, POST /intents/quote, POST /accounts, GET /accounts/balance, POST /policies, GET /policies/... HMAC hmacPlugin (src/auth/hmacPlugin.ts)
POST /admin/fee-rules/dry-run, PATCH /admin/debug-level, POST /admin/intents/:id/resolve HMAC (де-факто, см. §6) hmacPlugin
adminPlugin (Authorization: Bearer …) объявлен, но не зарегистрирован в server.ts

2. HMAC: заголовки

Заголовок Обязателен Описание
X-Service-Id да Идентификатор сервиса-клиента (ключ в SERVICE_SECRETS). Например: auth-center, nginx-gateway, kyc-service.
X-Timestamp да Unix-time в секундах (целое число). Допустимое окно — ±60 секунд от текущего серверного времени.
X-Signature да HMAC-SHA256 в hex (lowercase). Формула — см. §3.
X-User-Id для user-context Integer userId. Требуется на endpoint-ах, оперирующих от лица пользователя (/intents/*). Не входит в подпись, парсится отдельно в intent-handler.
Content-Type для тел запросов application/json для POST/PATCH с телом.

Все заголовки чувствительны к регистру только на уровне Node.js (он приводит их к lowercase автоматически) — можно слать в любом регистре.

3. Формула подписи

Подпись строится точно так, как реализовано в src/auth/hmac.ts:

signingString = `${timestamp}\n${METHOD}\n${PATH}\n${bodyHash}`
signature     = hex( HMAC_SHA256( serviceSecret, signingString ) )

Где:

  • timestamp — то же значение, что отправляется в X-Timestamp (unix seconds).
  • METHOD — HTTP-метод в верхнем регистре (POST, GET, PATCH, …). На стороне сервера приводится через request.method.toUpperCase().
  • PATHrequest.url, то есть path + querystring (например /intents/abc?foo=1). Hostname и схема не входят.
  • bodyHashsha256hex(rawBody) (hex, lowercase). Для пустого тела — sha256hex("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
  • serviceSecret — значение из env-словаря SERVICE_SECRETS (см. §5) по ключу X-Service-Id.
  • Разделитель — \n (один LF, не CRLF).

Сравнение подписи на сервере выполняется через timingSafeEqual после декодирования из hex, поэтому регистр hex-строки должен быть согласован (createHmac возвращает lowercase).

Важно: rawBody берётся через плагин fastify-raw-body (runFirst: true), то есть до JSON-парсера. Если клиент пересериализует JSON с другими пробелами/порядком ключей — подпись развалится. Подписывайте именно те байты, которые отправляете в сокет.

4. Replay-защита

verifyHmacRequest отбрасывает запрос, если Math.abs(now - timestamp) > 60 (секунд). См. src/auth/hmac.ts:32.

  • На клиенте: брать Math.floor(Date.now() / 1000) непосредственно перед отправкой.
  • При больших clock skew между контейнерами синхронизировать NTP — обходного флага нет.
  • Идемпотентность писем достигается отдельно — через Idempotency-Key в теле intent-запросов, не через HMAC.

5. Хранение секретов

Секреты задаются в env-переменной SERVICE_SECRETS — JSON-объект { serviceId: secret }:

# .env
SERVICE_SECRETS='{"auth-center":"3f9c…","nginx-gateway":"a71b…","kyc-service":"e44d…"}'

Правила:

  • Один секрет ↔ один serviceId. Не делить секрет между сервисами — потеряется аудит.
  • Длина секрета — минимум 32 байта энтропии (рекомендация: openssl rand -hex 32).
  • Никогда не коммитить .env. В production секрет приходит из docker secrets / kube Secret.
  • Ротация: добавить новый ключ → раскатать новый секрет на все клиенты → удалить старый ключ из SERVICE_SECRETS.

Добавление нового клиента описано в ../cookbook/add-service-key.md (TODO — пока не написан).

6. Admin-endpoints и adminPlugin

В коде существует отдельный плагин src/auth/adminPlugin.ts, который проверяет:

Authorization: Bearer <ADMIN_SECRET>

через timingSafeEqual против config.ADMIN_SECRET. По задумке он должен прикрывать /admin/* отдельной схемой (Bearer вместо HMAC).

Фактическое положение дел в src/server.ts (master, на момент 2026-05-29):

await app.register(async (scope) => {
  await scope.register(hmacPlugin)          // ← HMAC
  await scope.register(intentRoutes)
  await scope.register(confirmRoutes)
  await scope.register(cancelRoutes)
  await scope.register(quoteRoutes)
  await scope.register(registerIppsRoutes)
  await scope.register(resolveIntentRoutes) // POST /admin/intents/:id/resolve
  await scope.register(feeRulesRoutes)      // POST /admin/fee-rules/dry-run
  await scope.register(debugLevelRoutes)    // PATCH /admin/debug-level
  await scope.register(policyRoutes)
})

То есть все три admin-маршрута (/admin/fee-rules/dry-run, /admin/debug-level, /admin/intents/:id/resolve) подписываются HMAC, как и обычные intent-роуты. adminPlugin нигде в server.ts не зарегистрирован.

Открытый вопрос: расхождение между документированным дизайном (Bearer для /admin/*) и фактической регистрацией (HMAC). Варианты разрешения: 1. Если Bearer — намеренная схема: вынести /admin/* в отдельный app.register со scope.register(adminPlugin) и убрать из HMAC-scope. 2. Если HMAC — намеренная схема: удалить неиспользуемый adminPlugin.ts и ADMIN_SECRET из конфига, чтобы не вводить читателей в заблуждение.

До разрешения вопроса клиенты /admin/* обязаны слать HMAC-заголовки, как обычные сервисы.

7. Пример: curl с подписью

В репозитории есть генератор: scripts/hmac-curl.js. Он читает SERVICE_SECRETS из .env и печатает готовую curl-команду:

# P2P-перевод от имени userId=1
node scripts/hmac-curl.js POST /intents auth-center \
  '{"idempotencyKey":"550e8400-e29b-41d4-a716-446655440000","operationType":"P2P_TRANSFER","amount":10000,"currency":"THB","recipientUserId":2}' \
  1 | bash

# Health (без HMAC — но скрипт всё равно подпишет, сервер просто проигнорирует)
curl -s http://localhost:3000/health

# Получение intent (GET, тело пустое)
node scripts/hmac-curl.js GET /intents/550e8400-e29b-41d4-a716-446655440000 auth-center '' 1 | bash

Эквивалентная подпись «руками» (Node.js, без .env-загрузчика):

const { createHash, createHmac } = require('crypto')

const serviceId = 'auth-center'
const secret    = process.env.AUTH_CENTER_SECRET   // из SERVICE_SECRETS["auth-center"]
const method    = 'POST'
const urlPath   = '/intents'                       // path + querystring, без host
const body      = JSON.stringify({ idempotencyKey: '...', operationType: 'P2P_TRANSFER', amount: 10000, currency: 'THB', recipientUserId: 2 })

const ts        = Math.floor(Date.now() / 1000)
const bodyHash  = createHash('sha256').update(body, 'utf8').digest('hex')
const sigString = `${ts}\n${method}\n${urlPath}\n${bodyHash}`
const signature = createHmac('sha256', secret).update(sigString, 'utf8').digest('hex')

await fetch(`http://localhost:3000${urlPath}`, {
  method,
  headers: {
    'Content-Type': 'application/json',
    'X-Service-Id': serviceId,
    'X-Timestamp':  String(ts),
    'X-Signature':  signature,
    'X-User-Id':    '1',
  },
  body,
})

И эквивалент на bash + openssl (для коротких ad-hoc проверок):

SERVICE_ID="auth-center"
SECRET="$(jq -r '."auth-center"' <<<"$SERVICE_SECRETS")"
METHOD="POST"
PATH_="/intents/quote"
BODY='{"operationType":"P2P_TRANSFER","amount":10000,"currency":"THB"}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $2}')
SIG=$(printf '%s\n%s\n%s\n%s' "$TS" "$METHOD" "$PATH_" "$BODY_HASH" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')

curl -s -X "$METHOD" "http://localhost:3000${PATH_}" \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: $SERVICE_ID" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 1" \
  -d "$BODY" | jq .

8. Типовые ошибки 401

Сообщение в error.message Причина
Missing HMAC headers: x-service-id, x-timestamp, x-signature Один из трёх заголовков не пришёл.
Unknown service: <id> X-Service-Id отсутствует в SERVICE_SECRETS.
Invalid HMAC signature Подпись не совпала. Чаще всего: пересериализованное тело, неверный PATH (забыли querystring), timestamp не вошёл в строку или ушёл за окно ±60 сек.

В логах PM ищите строки auth: HMAC rejected — … (hmacPlugin.ts) — там же выводятся serviceId, method, url для диагностики.

9. Что НЕ является аутентификацией

  • X-User-Id — это контекст, а не auth. Сервер доверяет ему ровно потому, что вызывающий сервис прошёл HMAC. Никогда не выставляйте X-User-Id на публичном периметре без HMAC-обёртки.
  • Idempotency-Key в теле — это идемпотентность, не replay-защита. Replay режется по X-Timestamp.
  • request.id (UUID, заголовок x-request-id в ответе) — это trace-id для логов, не auth.

10. Связанные документы