All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m3s
Deploy Trade-In / build-backend (push) Successful in 1m9s
Deploy Trade-In / deploy (push) Successful in 1m24s
PR-C платёжного контура: token.py (sign + verify_notification_token, оба эталонных вектора Т-Банка перепроверены независимо), receipt.py (54-ФЗ ФФД 1.05, целые копейки), tbank_client.py (Init/GetState/CheckOrder/Confirm/Cancel, таймаут 15с, 4xx не ретраится). Слой инертный: 0 импортёров, роутеров нет. Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
98 lines
5.4 KiB
Python
98 lines
5.4 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`/`float` → строка без
|
||
экспоненциальной записи; `str` — как есть.
|
||
3. Добавляем пару `Password: <пароль_терминала>`.
|
||
4. Сортируем пары по имени ключа (лексикографически по строке ключа),
|
||
конкатенируем ТОЛЬКО значения (не ключи и не имена) в одну строку.
|
||
5. SHA-256 (UTF-8) от строки, hex-digest в нижнем регистре.
|
||
|
||
Эталонные векторы (см. `tests/test_payments_token.py`) сняты дословно с
|
||
doc-портала — оба подтверждены живым запросом, не выдуманы.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import hmac
|
||
from typing import Any
|
||
|
||
_EXCLUDED_KEYS = frozenset({"Token"})
|
||
|
||
|
||
def _stringify_value(value: bool | int | float | str) -> str:
|
||
"""Приводит плоское значение к строке по правилам Т-Банка.
|
||
|
||
`bool` проверяем ДО `int`: в Python `bool` — подкласс `int`
|
||
(`isinstance(True, int) is True`), поэтому порядок веток важен —
|
||
иначе `True` попал бы в ветку int и дал `"1"` вместо `"true"`.
|
||
"""
|
||
if isinstance(value, bool):
|
||
return "true" if value else "false"
|
||
if isinstance(value, int):
|
||
return str(value)
|
||
if isinstance(value, float):
|
||
# `format(..., "f")` — фиксированная нотация, Python никогда не
|
||
# добавляет экспоненту при presentation type 'f' (в отличие от
|
||
# str()/repr(), которые для очень больших/малых float дают "1e+21").
|
||
text = format(value, "f")
|
||
if "." in text:
|
||
text = text.rstrip("0").rstrip(".")
|
||
return text
|
||
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`
|
||
(нечего сравнивать) — вызывающая сторона обязана трактовать это как
|
||
отказ в обработке нотификации, а не как «пропустить проверку».
|
||
"""
|
||
received_token = payload.get("Token")
|
||
if not isinstance(received_token, str) or not received_token:
|
||
return False
|
||
expected_token = sign(payload, password)
|
||
return hmac.compare_digest(expected_token, received_token)
|