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

Типичная схема human-in-the-loop для платежного агента выглядит так: агент готовит перевод, интерфейс показывает кнопку «Подтвердить», пользователь нажимает, платеж уходит. Выглядит безопасно, но кнопка сама по себе ничего не гарантирует. Если система не проверяет, кто нажал и на что именно было дано согласие, то нажатие может прийти от самого агента, его можно переиспользовать, а после согласования можно подменить сумму или получателя.
В этой статье мы соберем небольшой прототип на стандартной библиотеке Python. В нем четыре рубежа, и каждый можно проверить отдельно:
- Ограниченная сессия агента. Агент работает с жесткими лимитами по сумме, получателям и времени.
- Привязка решения к операции. Пользователь согласует хеш канонического описания конкретного платежа, а не абстрактное «да».
- Аутентифицированное одноразовое подтверждение. Подтверждение подписывается ключом устройства пользователя и действует один раз в течение короткого окна.
- Внешнее подписание. Ключ, который проводит платеж, хранится в отдельном сервисе. Агент его не видит, а сервис сам перепроверяет все предыдущие рубежи.
Результат: прототип, в котором легитимный платеж проходит, а повтор, подмена суммы, поддельная подпись и выход за лимиты блокируются с понятной причиной.
Модель угроз
Исходим из того, что агент может быть скомпрометирован. Например, через 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
Если у вас вывод отличается, найдите, какой рубеж пропустил атаку, и не идите дальше, пока не разберетесь. Еще несколько проверок, которые стоит добавить самостоятельно:
- Поставьте
ApprovalService(..., ttl_s=0)и убедитесь, что срабатываетapproval expired. - Временно уберите
op_idиз проверки подписанта и посмотрите, что повтор тогда останавливает только одноразовый nonce. Это наглядно показывает, зачем нужна эшелонированная защита. - Поднимите лимит сессии выше
hard_limit_minorи проверьте, что подписант всё равно отклоняет крупную сумму. - Оформите эти сценарии как тесты (
unittestилиpytest) и запускайте их в CI при каждом изменении политики.
Типовые ошибки
- Флаг подтверждения в выводе агента. Поле
"approved": trueв JSON от модели — это не подтверждение. Его может сгенерировать сама модель. - Подтверждение без привязки к операции. Токен вида «пользователь разрешил платежи на 10 минут» превращает одно согласие в карт-бланш.
- Повторно используемый nonce. Nonce нужно удалять атомарно в момент проверки, а не после успешного проведения. Иначе две параллельные заявки пройдут по одному подтверждению.
- Экран подтверждения рисует агент. Если описание операции для пользователя формирует модель, привязка к хешу теряет смысл: человек согласует не то, что подписывается.
- Ключ подписания в окружении агента. Переменная окружения, доступная инструментам агента, через prompt injection становится доступна и атакующему.
- Неполное намерение. Валюта, счет списания или назначение, не попавшие в канонический объект, можно подменить без изменения хеша.
- Сравнение подписей через
==. Используйтеhmac.compare_digest, чтобы не допускать утечек по времени выполнения.
Ограничения прототипа
- HMAC на устройстве — учебная замена WebAuthn. В продакшене нужны асимметричные ключи, аттестация устройства и регистрация ключей через отдельный защищенный поток.
- Хранилище nonce и множество
_used_opsживут в памяти. Для нескольких инстансов нужно общее хранилище с атомарной операцией «проверить и удалить» и журнал аудита. - Прототип не защищает от скомпрометированного устройства пользователя и от ситуации, когда пользователя убедили подтвердить мошеннический платеж. Здесь помогают задержки для новых получателей, второй согласующий для крупных сумм и мониторинг аномалий.
- Требования регуляторов и платежных провайдеров (строгая аутентификация, хранение журналов и т. д.) зависят от юрисдикции, и в статье они не рассматриваются.
- Код показывает архитектурный принцип и не заменяет аудит безопасности.
Что дальше
Главная мысль: подтверждение человеком становится рубежом безопасности только тогда, когда система может проверить личность согласующего, связь его решения с конкретной операцией и одноразовость этого решения, а деньги проводит компонент, недоступный агенту. Другие практические руководства по изоляции и ограничению агентов собраны в разделе гайдов, а термины из статьи объяснены в глоссарии.