gendesign/tradein-mvp/backend/app/services/payments/token.py
bot-backend 00d1f78668
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
fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе
2026-08-06 15:48:57 +03:00

141 lines
8.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Подпись `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