Аутентификация 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().PATH—request.url, то есть path + querystring (например/intents/abc?foo=1). Hostname и схема не входят.bodyHash—sha256hex(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 }:
Правила:
- Один секрет ↔ один
serviceId. Не делить секрет между сервисами — потеряется аудит. - Длина секрета — минимум 32 байта энтропии (рекомендация:
openssl rand -hex 32). - Никогда не коммитить
.env. В production секрет приходит изdocker secrets/ kubeSecret. - Ротация: добавить новый ключ → раскатать новый секрет на все клиенты → удалить старый ключ из
SERVICE_SECRETS.
Добавление нового клиента описано в ../cookbook/add-service-key.md (TODO — пока не написан).
6. Admin-endpoints и adminPlugin¶
В коде существует отдельный плагин src/auth/adminPlugin.ts, который проверяет:
через 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. Связанные документы¶
../cookbook/add-service-key.md— (TODO) как завести нового клиента и раскатать секрет../intents.md— endpoint-ы, требующие HMAC +X-User-Id../admin.md— admin-операции (/admin/*), на которые распространяется §6.../../../src/auth/hmac.ts,../../../src/auth/hmacPlugin.ts,../../../src/auth/adminPlugin.ts— исходники.