Самое важное из мира AI — в канале MAX AgentLabОтдельные разборы, инструменты и практические схемы Читайте Agent Lab в Telegram Разборы, кейсы и новости о практических AI-агентах без лишней воды.

Безопасное подтверждение платежей AI-агента: четыре проверяемых рубежа

Безопасное подтверждение платежей AI-агента: четыре проверяемых рубежа
Temporary fallback cover; replace in editorial pass.

Уровень: продвинутый · Время чтения: до 12 минут · Опубликовано: 7 октября 2026

Типичная схема human-in-the-loop для платежного агента выглядит так: агент готовит перевод, интерфейс показывает кнопку «Подтвердить», пользователь нажимает, платеж уходит. Выглядит безопасно, но кнопка сама по себе ничего не гарантирует. Если система не проверяет, кто нажал и на что именно было дано согласие, то нажатие может прийти от самого агента, его можно переиспользовать, а после согласования можно подменить сумму или получателя.

В этой статье мы соберем небольшой прототип на стандартной библиотеке Python. В нем четыре рубежа, и каждый можно проверить отдельно:

  1. Ограниченная сессия агента. Агент работает с жесткими лимитами по сумме, получателям и времени.
  2. Привязка решения к операции. Пользователь согласует хеш канонического описания конкретного платежа, а не абстрактное «да».
  3. Аутентифицированное одноразовое подтверждение. Подтверждение подписывается ключом устройства пользователя и действует один раз в течение короткого окна.
  4. Внешнее подписание. Ключ, который проводит платеж, хранится в отдельном сервисе. Агент его не видит, а сервис сам перепроверяет все предыдущие рубежи.

Результат: прототип, в котором легитимный платеж проходит, а повтор, подмена суммы, поддельная подпись и выход за лимиты блокируются с понятной причиной.

Модель угроз

Исходим из того, что агент может быть скомпрометирован. Например, через prompt injection из письма, веб-страницы или ответа инструмента. Значит, всё, что агент формирует сам (текст для пользователя, параметры вызова, флаг «approved=true»), считается недоверенным. Безопасность должна держаться на проверках, которые выполняются вне агента и которые он не может обойти, даже если полностью контролирует свой вывод.

В эту статью не входят компрометация устройства пользователя, социальная инженерия самого пользователя и атаки на банковскую инфраструктуру. Об этом подробнее в разделе об ограничениях.

Шаг 1. Подготовка окружения

Нужен только Python 3.10+ без сторонних пакетов. Команды ничего не устанавливают глобально и не обращаются к сети:

mkdir payment-approval-proto && cd payment-approval-proto
python3 --version
touch payment_proto.py

Весь код ниже кладем в payment_proto.py. Ключи генерируются в памяти при каждом запуске, никакие секреты на диск не пишутся.

Шаг 2. Каноническое намерение и его хеш

Согласовывать можно только то, что однозначно сериализуется. Описываем платеж словарем, суммы храним в минимальных единицах валюты (копейки, центы), чтобы не было ошибок округления с плавающей точкой. Сериализация отсортирована и без пробелов, поэтому одинаковое намерение всегда дает одинаковый хеш.

import hashlib, hmac, json, secrets, time


def canonical(intent: dict) -> bytes:
    return json.dumps(intent, sort_keys=True, separators=(",", ":"),
                      ensure_ascii=False).encode("utf-8")


def intent_hash(intent: dict) -> str:
    return hashlib.sha256(canonical(intent)).hexdigest()

В намерение обязательно включаем всё, что влияет на деньги: получателя, сумму, валюту, назначение и уникальный идентификатор операции. Если какое-то поле не попало в хеш, его можно поменять после согласования, и проверка этого не заметит.

Шаг 3. Рубеж 1 — ограниченная сессия

Сессия агента задает рамки до того, как дело дойдет до пользователя. Так пользователь не будет получать запросы на заведомо недопустимые операции, а нагрузка на его внимание снизится.

class AgentSession:
    """Рубеж 1: что агенту вообще разрешено предлагать."""

    def __init__(self, max_amount_minor: int, allowed_payees: set, ttl_s: int):
        self.max_amount_minor = max_amount_minor
        self.allowed_payees = set(allowed_payees)
        self.expires_at = time.time() + ttl_s

    def check(self, intent: dict) -> None:
        if time.time() > self.expires_at:
            raise PermissionError("session expired")
        if intent["payee"] not in self.allowed_payees:
            raise PermissionError("payee not in session allowlist")
        if intent["amount_minor"] > self.max_amount_minor:
            raise PermissionError("amount exceeds session limit")

Объект сессии должен жить на стороне оркестратора, а не в контексте модели. Агент получает только ссылку на сессию и не может расширить лимиты сам.

Шаг 4. Рубежи 2 и 3 — одноразовое подтверждение, привязанное к операции

Сервер подтверждений выдает одноразовый nonce, жестко связанный с хешем намерения и сроком действия. Устройство пользователя подписывает пару nonce:hash своим ключом. Сервер проверяет подпись, совпадение хеша и срок, после чего сразу удаляет nonce, чтобы его нельзя было использовать повторно.

class UserDevice:
    """Имитация устройства пользователя. В продакшене это WebAuthn/passkey."""

    def __init__(self):
        self.key = secrets.token_bytes(32)

    def approve(self, nonce: str, shown_hash: str) -> str:
        msg = f"{nonce}:{shown_hash}".encode()
        return hmac.new(self.key, msg, "sha256").hexdigest()


class ApprovalService:
    """Рубежи 2 и 3: решение привязано к операции, аутентифицировано и одноразово."""

    def __init__(self, registered_device_key: bytes, ttl_s: int = 120):
        self._device_key = registered_device_key
        self._pending = {}
        self.ttl_s = ttl_s

    def challenge(self, h: str) -> str:
        nonce = secrets.token_hex(16)
        self._pending[nonce] = (h, time.time() + self.ttl_s)
        return nonce

    def verify(self, nonce: str, h: str, signature: str) -> dict:
        entry = self._pending.pop(nonce, None)  # одноразовость: удаляем сразу
        if entry is None:
            raise PermissionError("unknown or already used nonce")
        bound_hash, expires_at = entry
        if time.time() > expires_at:
            raise PermissionError("approval expired")
        if not hmac.compare_digest(bound_hash, h):
            raise PermissionError("intent does not match approved operation")
        expected = hmac.new(self._device_key, f"{nonce}:{h}".encode(),
                            "sha256").hexdigest()
        if not hmac.compare_digest(expected, signature):
            raise PermissionError("invalid user signature")
        return {"intent_hash": h, "nonce": nonce, "approved_at": time.time()}

Здесь есть важное упрощение. HMAC — симметричная схема, поэтому сервер знает ключ устройства. В реальной системе используйте WebAuthn: на устройстве хранится закрытый ключ, сервер держит только открытый, а данные операции передаются в challenge. Логика проверок при этом не меняется: тот же nonce, та же привязка к хешу и тот же срок.

Отдельно о том, что видит пользователь. Экран подтверждения должен строиться из канонического намерения средствами доверенного интерфейса, а не из текста, который сгенерировал агент. Иначе агент покажет «100 ₽ в кофейню», а на подпись отправит другой хеш.

Шаг 5. Рубеж 4 — внешнее подписание

Подписывающий сервис — последний барьер. Он получает намерение целиком, сам считает хеш и сам проверяет подтверждение, ничего не принимая на веру от вызывающей стороны. Кроме того, у него собственный жесткий лимит, не зависящий от сессии: даже ошибка в настройке сессии не позволит провести крупную сумму.

class ExternalSigner:
    """Рубеж 4: ключ проведения платежа недоступен агенту."""

    def __init__(self, approvals: ApprovalService, hard_limit_minor: int):
        self._key = secrets.token_bytes(32)
        self._approvals = approvals
        self._hard_limit_minor = hard_limit_minor
        self._used_ops = set()

    def sign(self, intent: dict, nonce: str, user_sig: str) -> str:
        if intent["amount_minor"] > self._hard_limit_minor:
            raise PermissionError("amount exceeds signer hard limit")
        if intent["op_id"] in self._used_ops:
            raise PermissionError("operation already signed")
        h = intent_hash(intent)  # пересчитываем сами
        self._approvals.verify(nonce, h, user_sig)
        self._used_ops.add(intent["op_id"])
        return hmac.new(self._key, canonical(intent), "sha256").hexdigest()

В учебном прототипе подписант и сервис подтверждений — объекты в одном процессе. В рабочей системе это отдельные сервисы с отдельными учетными данными. Агент может вызвать только sign через узкий API и не имеет доступа ни к _key, ни к хранилищу nonce.

Шаг 6. Сценарий: легитимный платеж и попытки обхода

Добавьте в конец файла демонстрацию. Каждая попытка атаки должна завершиться блокировкой с конкретной причиной.

def attempt(label, fn):
    try:
        fn()
        print(f"OK       {label}")
    except PermissionError as e:
        print(f"BLOCKED  {label}: {e}")


if __name__ == "__main__":
    device = UserDevice()
    approvals = ApprovalService(registered_device_key=device.key)
    signer = ExternalSigner(approvals, hard_limit_minor=50_000)
    session = AgentSession(max_amount_minor=20_000,
                           allowed_payees={"ACME-UTILITIES"}, ttl_s=600)

    intent = {"op_id": secrets.token_hex(8), "payee": "ACME-UTILITIES",
              "amount_minor": 12_500, "currency": "RUB",
              "purpose": "invoice 2026-10"}

    session.check(intent)
    h = intent_hash(intent)
    nonce = approvals.challenge(h)
    sig = device.approve(nonce, h)  # пользователь видит intent и подтверждает

    attempt("legit payment", lambda: signer.sign(intent, nonce, sig))
    attempt("replay same approval", lambda: signer.sign(intent, nonce, sig))

    # подмена суммы после согласования
    intent2 = dict(intent, op_id=secrets.token_hex(8))
    n2 = approvals.challenge(intent_hash(intent2))
    s2 = device.approve(n2, intent_hash(intent2))
    tampered = dict(intent2, amount_minor=19_900)
    attempt("amount changed after approval", lambda: signer.sign(tampered, n2, s2))

    # агент сам подделывает подтверждение без ключа устройства
    intent3 = dict(intent, op_id=secrets.token_hex(8))
    n3 = approvals.challenge(intent_hash(intent3))
    attempt("forged user signature", lambda: signer.sign(intent3, n3, "00" * 32))

    # выход за рамки сессии
    rogue = dict(intent, op_id=secrets.token_hex(8), payee="UNKNOWN-LLC")
    attempt("payee outside session", lambda: session.check(rogue))

Запуск:

python3 payment_proto.py

Проверка результата

По логике кода скрипт должен напечатать одну строку OK и четыре строки BLOCKED:

OK       legit payment
BLOCKED  replay same approval: operation already signed
BLOCKED  amount changed after approval: intent does not match approved operation
BLOCKED  forged user signature: invalid user signature
BLOCKED  payee outside session: payee not in session allowlist

Если у вас вывод отличается, найдите, какой рубеж пропустил атаку, и не идите дальше, пока не разберетесь. Еще несколько проверок, которые стоит добавить самостоятельно:

Типовые ошибки

Ограничения прототипа

Что дальше

Главная мысль: подтверждение человеком становится рубежом безопасности только тогда, когда система может проверить личность согласующего, связь его решения с конкретной операцией и одноразовость этого решения, а деньги проводит компонент, недоступный агенту. Другие практические руководства по изоляции и ограничению агентов собраны в разделе гайдов, а термины из статьи объяснены в глоссарии.