All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
141 lines
8.6 KiB
Python
141 lines
8.6 KiB
Python
"""Подпись `Token` запросов Т-Банк эквайринга и проверка подписи нотификаций.
|
||
|
||
Docs (проверено живым запросом к doc-порталу, 2026-08-06):
|
||
- https://developer.tbank.ru/eacq/intro/developer/token — формирование Token.
|
||
- https://developer.tbank.ru/eacq/intro/developer/notification
|
||
(раздел «Проверить токен уведомлений») — тот же алгоритм для входящих
|
||
нотификаций.
|
||
|
||
Алгоритм (идентичен для исходящего запроса и для проверки нотификации):
|
||
|
||
1. Берём ТОЛЬКО плоские поля payload: исключаем ключ `Token`, исключаем
|
||
`None`, исключаем значения-`dict`/`list` (документация формулирует это
|
||
как «кроме параметра Token и вложенных объектов (Data, Receipt)» —
|
||
здесь обобщено до правила по ТИПУ значения, а не по имени ключа: любые
|
||
вложенные объекты/массивы, будь то `Receipt`, `DATA`, `Data`, `Items`
|
||
или `Shops`, отсекаются одинаково, потому что все они не примитивы).
|
||
2. `bool` → `"true"`/`"false"` (нижний регистр); `int` → строка через `str()`;
|
||
`str` — как есть. `float` НЕ поддерживается — падаем явной ошибкой (формат
|
||
дробных чисел не задокументирован Т-Банком, см. `_stringify_value`).
|
||
3. Добавляем пару `Password: <пароль_терминала>`.
|
||
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
|
||
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
|
||
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
|
||
|
||
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
|
||
doc-портала — оба подтверждены живым запросом, не выдуманы.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
import logging
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_EXCLUDED_KEYS = frozenset({"Token"})
|
||
|
||
|
||
class TokenSigningError(ValueError):
|
||
"""Поле не может быть однозначно сериализовано в подписываемую строку."""
|
||
|
||
|
||
def _stringify_value(value: bool | int | float | str) -> str:
|
||
"""Приводит плоское значение к строке по правилам Т-Банка.
|
||
|
||
`bool` проверяем ДО `int`: в Python `bool` — подкласс `int`
|
||
(`isinstance(True, int) is True`), поэтому порядок веток важен —
|
||
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
|
||
|
||
`float` НЕ поддерживается — падаем с `TokenSigningError`, а не
|
||
угадываем формат. Документация Т-Банка не описывает сериализацию
|
||
дробных чисел в подписи; прежняя реализация (`format(value, "f")` +
|
||
rstrip нулей) была неподтверждённой догадкой, и она расходится с тем,
|
||
что реально уходит в JSON-теле запроса: `0.1 + 0.2` подписывался бы
|
||
как `"0.3"`, а `json.dumps(0.1 + 0.2)` даёт `"0.30000000000000004"` —
|
||
Token не соответствовал бы фактическому телу. Денежные суммы (`Amount`)
|
||
в этом API всегда целые копейки (`int`); для денежного пути правильнее
|
||
явно упасть на нецелом значении, чем подписать не то, что уйдёт в сеть.
|
||
"""
|
||
if isinstance(value, bool):
|
||
return "true" if value else "false"
|
||
if isinstance(value, int):
|
||
return str(value)
|
||
if isinstance(value, float):
|
||
raise TokenSigningError(
|
||
f"float в подписываемых полях не поддерживается (получено {value!r}) — "
|
||
"формат дробных чисел не описан в документации Т-Банка, см. docstring "
|
||
"_stringify_value"
|
||
)
|
||
return str(value)
|
||
|
||
|
||
def _flatten_signable_fields(payload: dict[str, Any]) -> dict[str, str]:
|
||
"""Плоские поля payload, готовые к конкатенации: без Token/None/dict/list."""
|
||
result: dict[str, str] = {}
|
||
for key, value in payload.items():
|
||
if key in _EXCLUDED_KEYS or value is None:
|
||
continue
|
||
if isinstance(value, dict | list):
|
||
continue
|
||
result[key] = _stringify_value(value)
|
||
return result
|
||
|
||
|
||
def sign(payload: dict[str, Any], password: str) -> str:
|
||
"""Считает `Token` для исходящего запроса (Init/GetState/CheckOrder/...).
|
||
|
||
`payload` — тело запроса ДО добавления поля `Token` (поле `Password`
|
||
самому передавать не нужно — функция добавляет его сама и удаляет
|
||
участие любых вложенных объектов автоматически).
|
||
"""
|
||
fields = _flatten_signable_fields(payload)
|
||
fields["Password"] = password
|
||
raw = "".join(fields[key] for key in sorted(fields))
|
||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def verify_notification_token(payload: dict[str, Any], password: str) -> bool:
|
||
"""Проверяет `Token` входящей нотификации: пересчёт + `hmac.compare_digest`.
|
||
|
||
`payload` — полное тело нотификации, включая присланный `Token` (сам
|
||
алгоритм сборки исключает ключ `Token` из подписи — см. `_EXCLUDED_KEYS`).
|
||
|
||
Возвращает `False`, если в payload нет строкового непустого `Token`
|
||
(нечего сравнивать) — вызывающая сторона обязана трактовать это как
|
||
отказ в обработке нотификации, а не как «пропустить проверку».
|
||
|
||
НИКОГДА не поднимает исключение — на любом враждебном/мусорном входе
|
||
(не `dict`, не-ASCII `Token`, поля, которые ломают сериализацию внутри
|
||
`sign()`) возвращает `False`. Это обязательное свойство для публичной
|
||
ручки нотификации (PR-D): необработанное исключение здесь — это
|
||
неаутентифицированный HTTP 500 в ответ банку, а любой ответ, отличный
|
||
от `"OK"`, банк трактует как временный сбой и ретраит уведомление
|
||
почасово в течение суток. Конкретные причины двух проверок ниже:
|
||
- `payload` не `dict` (например список) → `.get()` кинул бы
|
||
`AttributeError` без явной проверки типа;
|
||
- `Token` с не-ASCII символами → `hmac.compare_digest` на строках
|
||
требует ASCII и иначе кидает `TypeError` (документированное
|
||
ограничение stdlib, не баг).
|
||
"""
|
||
if not isinstance(payload, dict):
|
||
return False
|
||
received_token = payload.get("Token")
|
||
if not isinstance(received_token, str) or not received_token or not received_token.isascii():
|
||
return False
|
||
try:
|
||
expected_token = sign(payload, password)
|
||
except Exception:
|
||
# Мусорное поле где-то ещё в payload (например float — см.
|
||
# `_stringify_value`) не должно валить проверку подписи в исключение.
|
||
logger.warning(
|
||
"verify_notification_token: sign() упал на входящем payload — трактуем как отказ",
|
||
exc_info=True,
|
||
)
|
||
return False
|
||
try:
|
||
return hmac.compare_digest(expected_token, received_token)
|
||
except TypeError:
|
||
return False
|