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