Перейти к содержимому

Agent Gateway: бесключевой программный доступ к 2D

Автономному агенту, который распоряжается чужими средствами (платёжный бот, автоматизатор казначейства, LLM), нужно от цепочки две вещи: читать состояние и подписывать транзакции. Чтение это простая половина. Весь риск сосредоточен в ключе.

Подписывающий ключ внутри процесса агента это одна из самых уязвимых точек в криптосистеме. Агенты по своей природе поглощают недоверенный ввод: промпты, веб-страницы, котировки от контрагентов. Prompt-injection или фишинг конфигурации достаточно, чтобы ключ подписал всё что угодно злоумышленнику. И поскольку ключ находится внутри агента, on-chain политика остаётся единственным слоем между атакующим и средствами. История агентской автоматизации в крипто это, по большей части, список украденных hot-wallet’ов.

Agent Gateway в 2D убирает ключ из агента. Агент никогда не держит приватный ключ. Он держит bearer-токен, авторизующий фиксированный набор операций над одним assigned wallet, тогда как подписывающий ключ кошелька живёт в отдельном 2d-hsm-анклаве внутри confidential VM. Шлюз общается с анклавом через Unix-domain socket, а агент со шлюзом через аутентифицированный HTTP. Запросы на запись от агента перепроверяются и подписываются внутри анклава под операторской политикой, а не самим агентом.

В этой статье разобрано, что шлюз предоставляет, модель авторизации, как assigned wallet привязывается к ключу в TEE, проверка proof-of-possession, подтверждающая, что ключ по-прежнему у анклава, и что оператору нужно включить, прежде чем всё это сдвинет хоть какие-то средства.

Шлюз доступен на https://poa.net/77/agent/v1 в production-сети POA, chain 77. Операции с кошельком требуют bearer-токен, capability и проходят rate limit по IP и агенту. Исключение — POST /agent/v1/register: публичная выдача нового токена и пустого assigned wallet, с отдельными лимитами регистрации. Она не подписывает платёж и не выдаёт средства. Legacy GET /faucet не публикуется.

ЭндпоинтCapabilityНазначение
GET /agent/v1/networkreadDiscovery. Возвращает версию API, метаданные цепочки, идентичность и capabilities агента, allowlist RPC-методов и действующие rate limits. Вызывать первым.
GET /agent/v1/balancereadБаланс assigned wallet в базовых единицах токена (USDC, 6 decimals) плюс 18-decimal форма для совместимости с eth_getBalance. Авторитетный баланс кошелька агента.
POST /agent/v1/rpcreadRead-only JSON-RPC прокси. Принимает один запрос или batch. Методы ограничены фиксированным allowlist.
POST /agent/v1/faucetfaucetВыплата на assigned wallet из казны фаусета, подписывается в анклаве. По умолчанию выключен.
POST /agent/v1/x402/authorizetransferАвторизация x402-платежа от assigned wallet с проверкой quote и signer policy.
POST /agent/v1/transferstransferПеревод с assigned wallet получателю, подписывается в анклаве. По умолчанию выключен.

Demo и facilitator x402 — отдельная публичная поверхность. Assigned wallets используют указанный выше gateway endpoint авторизации с capability transfer. Неоплаченный GET https://poa.net/77/x402/demo отвечает 402. Asset — прекомпайл NativeTransferAuth, не Ethereum mainnet USDC. См. x402.

Регистрация не включает расходование средств. Faucet, transfer и x402 имеют отдельные выключенные по умолчанию переключатели, помимо AGENT_GATEWAY_ENABLED и требуемой capability токена:

ПоверхностьВключение и лимиты платежейПовторы и граница восстановления
FaucetAGENT_FAUCET_ENABLED; оператор задаёт AGENT_FAUCET_PAYOUT_AMOUNT, AGENT_FAUCET_COOLDOWN_MS и AGENT_FAUCET_DAILY_LIMIT. Получатель — только assigned wallet вызывающего; публичный токен не имеет faucet.Обязателен idempotency_key: повтор использует сохранённый результат, изменённый запрос конфликтует. FundingGate проверяет treasury и получателя, backup/identity и применимые restore evidence либо pre-restore ceilings.
TransferAGENT_TRANSFER_ENABLED; AGENT_TRANSFER_PER_TX_LIMIT и AGENT_TRANSFER_DAILY_LIMIT по умолчанию равны нулю. Опциональный agent_transfer_recipient_allowlist сужает получателей.Обязателен idempotency_key в пределах агента и вида операции; изменение payload вызывает конфликт. FundingGate проверяет исходный кошелёк и применимые restore evidence либо pre-restore wallet ceiling.
x402 authorizationAGENT_X402_ENABLED; точные сумма и получатель заданы AGENT_X402_AMOUNT и AGENT_X402_PAY_TO. AGENT_X402_MAX_TIMEOUT_SECONDS ограничивает срок действия (по умолчанию 180 секунд). В production нужен live Gateway signer.Идемпотентность: (agent_id, kind, idempotency_key), ключ по умолчанию x402:<nonce>; изменённые запросы конфликтуют. Действуют проверка привязки ключа и signer policy, но FundingGate faucet/transfer, restore-evidence ceilings и суточные transfer-квоты не применяются.

Для faucet и transfer pre-restore ceilings разрешаются в режиме zero_only, если нет явного исключения approved_non_zero с активным локальным одобрением. Ограниченное по времени исключение не доказывает завершённое disaster recovery. x402 требует отдельной проверки: выключение transfers или уменьшение их квот не выключает x402 и не ограничивает его совокупные расходы. До включения отдельно проверьте signer policy x402 и готовность восстановления. URL HTTP-ресурса связан с request hash Gateway, а не с криптографической авторизацией платежа.

RPC-прокси не делает сквозную передачу. Он требует закрытый allowlist методов, перечисленный в модуле RpcProxy:

eth_chainId, eth_blockNumber, eth_getBalance,
eth_getTransactionByHash, eth_getTransactionReceipt, eth_getBlockByNumber

Любой другой метод отбрасывается с JSON-RPC-ошибкой, содержащей allowlist. Здесь нет eth_sendRawTransaction, нет eth_call в write-точку входа и нет способа отправить state-changing транзакцию через /rpc. Агентские записи идут через специализированные /faucet и /transfers и подписываются в анклаве; сырой RPC никогда не является write-путём, какие бы capabilities у агента ни были. Batch ограничен 100 запросами и 64 KB в кодировке, поэтому один агент не сможет удерживать соединение oversized-батчем.

Рекомендуемый первый вызов это discovery:

Окно терминала
curl -s -H "Authorization: Bearer $AGENT_TOKEN" \
https://poa.net/77/agent/v1/network | jq .

Ответ описывает цепочку (id 77, нативный токен USDC, 6 decimals, последний блок), вызывающего агента (agent_id, assigned_wallet, capabilities), allowlist RPC и действующие rate limits. Считайте /network контрактом: агент, прочитавший его, адаптируется к конкретным методам и лимитам без хардкода.

Одна сознательная асимметрия. /balance возвращает баланс только assigned wallet. eth_getBalance внутри /rpc принимает произвольный адрес. Read-only инспекция всей сети (баланс любого адреса, receipt любой подтверждённой транзакции) это read-операция, и :read-capability агент может её выполнять. Изменения состояния нет.

Откройте Create API token. Когда регистрация включена, приглашение и email не нужны. Токен возвращается один раз; сервер хранит только хеш секрета. Аккаунт получает отдельный проверенный ключ анклава с резервной копией и фиксированные capabilities read и transfer. Клиент не может указать кошелёк, capability или секрет токена.

Регистрация выключена в конфигурации, а начальная политика БД находится на паузе. После включения лимиты по умолчанию: 3 успешные регистрации на IP за сутки UTC, 100 на всю сеть за сутки UTC, общий потолок 1 000 и резерв 10 проверенных ключей. Попытки дополнительно ограничены 10 на IP и 120 глобально в минуту. IPv6 группируется по /64; IP-квота ограничивает злоупотребления, но не доказывает уникальность человека. Глобальные потолки ограничивают распределённую атаку.

Квота выдачи и назначение ключа фиксируются вместе под блокировкой БД. Неудачный provisioning всё равно расходует отдельный бюджет попыток. Пустой пул или достижение резерва возвращают 503; rate limit — 429 с Retry-After. При потере HTTP-ответа кошелёк может быть выделен без доставки токена, поэтому автоматический повтор запрещён.

Закрытая консоль оператора показывает выдачи, отказы, запас ключей, последние аккаунты и использование API на текущей ноде. В ней можно приостановить регистрацию, изменить квоты и отключить аккаунт без переиспользования ключа. Публичные маршруты Explorer не открывают admin API.

Каждый запрос wallet API аутентифицируется против agent identity: строки в базе с идентификатором токена и хешем секрета, assigned_wallet, флагом enabled и набором capabilities. Capabilities это закрытый enum ровно из трёх значений: read, faucet и transfer. Каждое проверяется своим конвейером маршрута, поэтому агент с read не дотянется до write-эндпоинта, как бы его ни вызывали. Запрос неизвестной capability отбрасывается как changeset-ошибка при провижининге, а не игнорируется молча.

Аутентификационный plug (AgentAuth) работает fail-closed. Запрос принимается, только если токен верифицируется против не отозванной, включённой идентичности с нужной маршруту capability. Плохой или отозванный токен и отключённая identity возвращают 401 agent_auth_failed. Валидный токен без нужной capability возвращает 403 agent_capability_required. Выключенный шлюз возвращает отдельный 404 (см. мастер-переключатель ниже), а не ошибку аутентификации.

Rate limit состоит из двух уровней, каждый под свою модель злоупотребления. Оба реализованы как ETS sliding window в AgentRateLimit:

  • Pre-authentication, по IP: 60 неудач аутентификации за 60 секунд. Защита от брутфорса токена. Учитываются только неуспешные аутентификации, поэтому легитимный клиент с большим числом запросов после одного успешного входа не штрафуется.
  • Post-authentication, по агенту: 600 запросов за 60 секунд. Ограничение runaway-клиента. Лимитирует, как быстро одна идентичность нагружает read-поверхность, независимо от числа IP.

Оба уровня возвращают стандартные 429. Pre-auth бакет ключуется по исходному IP, поэтому за L7 reverse proxy шлюзу нужно передать allowlist CIDR прокси (AGENT_TRUSTED_PROXIES). Без него все запросы выглядят приходящими с адреса прокси, и pre-auth лимит схлопывается в один общий бакет. Это та же логика proxy/CIDR, что у операторского bridge-эндпоинта; переменные разнесены для разных поверхностей. Публичный production URL это HTTPS. Bearer-токен по plaintext HTTP можно перехватить, и это обесценивает всю модель доверия ниже.

У всего шлюза есть мастер-переключатель. Если AGENT_GATEWAY_ENABLED не задан или равен чему-то кроме литерала "true", эндпоинты возвращают 404, а фоновые воркеры не стартуют. По умолчанию выключено.

У каждой agent identity ровно один assigned_wallet: 20-байтовый EVM-адрес, вычисляемый так же, как любой другой адрес в 2D (см. Адреса). Что делает его assigned, так это то, что приватный ключ генерируется и хранится 2d-hsm анклавом, secp256k1-подписантом внутри confidential VM. Это отдельный подписант от подписывающей роли оператора моста, описанного в HSM-топологии. Агент никогда не видит ключ. Шлюз никогда не видит ключ. Ключ существует только внутри анклава.

Анклав это небольшая Rust-программа, говорящая на CBOR-over-framing протоколе через Unix-domain socket. В продакшене шлюз подключается к host-side сокету (AGENT_SIGNER_2D_HSM_ENDPOINT, путь unix://); host-side прокси терминирует vsock-транспорт анклава. Шлюз пинит соединение на профиле agent_gateway и отвергает любой другой. Протокол несёт объект capability для каждой привилегированной операции, domain-separated под 2d-hsm/agent-cap/v1. Анклав обеспечивает уровни полномочий на своей стороне: генерация новых ключей и экспорт зашифрованных backup’ов это admin-tier, восстановление backup’а это recovery-tier, финансовый scope (конфигурация treasury) принудительно enclave-bound. Fleet-wide spend authority по построению недоступна: нет формы capability, которая её авторизует.

Assigned wallet’ы берутся из transfer-key pool. Анклав генерирует ключи батчами; каждый батч экспортируется в зашифрованный backup и верифицируется, прежде чем стать доступным. Провижининг забирает один свободный ключ на новую идентичность в одной транзакции в IdentityProvisioner:

SELECT … FOR UPDATE OF k SKIP LOCKED LIMIT 1

Поэтому два конкурирующих провижининга всегда забирают разные ключи, никогда одну строку. Идентичность вставляется с assigned_wallet и agent_transfer_key_ref (64-символьным hex-хэндлом в keystore анклава) от захваченного ключа, а ключ в той же транзакции переводится в assigned. Пустой пул возвращает :agent_key_pool_exhausted; пул, все строки которого захвачены конкурирующими claim’ами, возвращает :agent_key_pool_busy. Ни в одном из случаев идентичность не создаётся. Продакшен-идентичности провижинируются только через этот путь. Публичная регистрация вызывает этот же provisioner с полями, выбранными сервером. Ручная вставка строк ограничена инвариантом назначения.

Пул поддерживается в разогретом состоянии: RefillWorker дозаполняет его до целевого размера, BatchSweeper проваливает батчи, застрявшие в промежуточном backup-состоянии, чтобы их входы были сверены, а BackupSink записывает каждый батч через temp-файл, atomic-rename и re-read SHA-256 проверку, прежде чем батч продвинется. Ключевой материал никогда не перемещается без зашифрованного backup-envelope и проверенной второй копии.

Bearer-токен сам по себе несёт все полномочия агента: кто украл его, тот действует от имени агента, пока токен не отзовут. Шлюз дополняет токен криптографической проверкой того, что анклав действительно держит приватный ключ за assigned wallet. Это identity proof, подпись proof-of-possession, которую анклав производит, а шлюз верифицирует по расписанию.

Подписываемый preimage domain-separated, поэтому он никогда не может быть валидной транзакцией или валидной подписью для чего-либо ещё. Он строится под EIP-191 0x19 signed-data prefix (тем байтом, который EIP-2718 намеренно оставляет нераспределённым, чтобы сохранить disjointness от typed transactions), с меткой 2d-hsm/agent-identity-proof/v1 и связывает четыре поля:

  • chain id развёртывания (77),
  • environment identifier, именующий развёртывание, под который собирался keystore анклава,
  • несжатый публичный ключ кошелька (65-байтовый SEC1, с проверкой on-curve),
  • и адрес (20 байт), который должен совпадать с адресом, выведенным из публичного ключа.

Анклав подписывает keccak256 от preimage на secp256k1 под EIP-2 low-S. Верификатор восстанавливает адрес из подписи и сверяет его с публичным ключом; proof, не верифицирующийся против хранимого ключа, отбрасывается. Полная конструкция в IdentityProof.

Proof устанавливает custody ключа, а не личность вызывающего. Он выполняется по расписанию, а не на каждый запрос, поэтому не мешает украденному bearer-токену авторизовать чтение, пока оператор его не отзовёт. Зато он предотвращает другое: никто, кроме анклава, не может заставить шлюз считать ключ кошелька здоровым. Токен авторизует чтение; засвидетельствовать, что ключ за этим чтением по-прежнему тот, что провижинировал оператор, может только анклав.

Шлюз поставляется dormant по умолчанию. При незаданном AGENT_SIGNER_LIVE_IDENTITY_ENABLED identity-вызовы идут в стаб, который на каждый запрос возвращает :agent_signer_protocol_unavailable. Это сознательно: так read-поверхность и фоновые воркеры работают против неподключённого подписанта, ничего не отключая. IdentitySweep работает и в dormant, и в live-режиме. Под dormant-стабом каждая идентичность попадает в класс :terminal_unknown (ниже), оставляющий кошельки нетронутыми, поэтому dormancy ничего не отключает.

Переключение на live выбирает SignerClient.Live, который выполняет реальный обмен proof-of-possession. Переключение gated обязательным canary, который прогоняет ту же конфигурацию, под которой будет работать нода, и сообщает, верифицирует ли анклав один assigned key:

  • exit 0: identity верифицирована, конфигурация совпадает с развёртыванием, переключать безопасно;
  • exit 1: нет кандидата для пробы (сначала заполнить ключ);
  • exit 2: не переключать. Либо mismatch конфигурации/протокола, либо неконклюзивный transient.

Canary это probe: он не делает мутаций в БД, поэтому даже вердикт о mismatch оставляет проверенный ключ в assigned. Гейт существует, потому что оба интересных режима отказа задаются конфигурацией. Незаданные chain id или environment identifier под Live превращаются в terminal-config finding каждый tick: шум, который ничего не верифицирует. Хуже, wrong-but-in-range chain id классифицируется как destructive. Подписанный proof не верифицируется, а следующий sweep отключил бы каждый устаревший assigned wallet. Этот исход fail-closed (ключи отключаются, не списываются; восстановление в том, чтобы починить конфиг и включить заново), но операционно серьёзен, поэтому canary обязателен.

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

КлассЧто означаетДействие
:destructiveКлюч больше не верифицируется против хранимой identity: key-ref mismatch, pubkey/address mismatch, провалившийся proofКлюч отключается. Без retry.
:retryableTransient-сбой транспорта до анклаваFinding записан; wallet цел; retry на следующем tick.
:terminal_config / :terminal_wire / :terminal_agent / :terminal_malformedМисконфигурация, protocol mismatch, malformed-ответFinding каждый tick; wallet цел.
:terminal_unknownНеожиданный raise, exit или throw; ответ «недоступно» от dormant-стабаFinding; wallet цел.

:destructive единственный класс, отключающий wallet. Все остальные сохраняют предфлип-постуру: transient-сбой или неподключённый подписант не должны выводить агентов офлайн. Отключение идемпотентно (повторный sweep по уже отключённому ключу это no-op), а восстановление в том, чтобы устранить причину и включить ключ через операторский write-путь.

Faucet, transfer и x402 authorization записывают agent_actions до подписи, с уникальностью (agent_id, kind, idempotency_key). Faucet и transfer требуют явный ключ; x402 использует kind :x402 и по умолчанию ключ x402:<nonce>. Изменение payload при том же ключе вызывает конфликт.

Faucet и transfer проходят requested → signed → submitted → confirmed, с терминальными состояниями ошибок. x402 authorize возвращает и при повторе отдаёт сохранённый подписанный payment payload; settlement выполняется отдельно facilitator-ом, поэтому gateway-строка авторизации не использует transfer submission/confirmation и transfer-квоты. Проверка привязки ключа не заменяет FundingGate faucet/transfer или проверку restore ceiling. См. x402 для ограничений quote, nonce и replay. У регистрации такого replay-контракта нет: после потери ответа нельзя повторять её автоматически.

Безопасность agent gateway опирается на три раздельных свойства:

  1. У агента токен, а не ключ. Каждая state-changing подпись производится внутри 2d-hsm анклава под операторской capability-политикой, а не агентом. Компрометация процесса агента даёт bearer-токен, действующий лишь для операций, которые он авторизует, и лишь до отзыва. Отзыв это per-identity флаг в базе, проверяемый на каждом запросе, поэтому отозванный токен перестаёт работать на следующем же запросе после того, как оператор его установил. Распространение это один запрос, а не cache TTL.
  2. Токен спарен с proof of possession. Re-verification подтверждает, что анклав всё ещё держит ключ за assigned wallet. Сам по себе токен этого подтвердить не может, а mismatch отключает кошелёк fail-closed.
  3. Поверхность закрыта, ограничена и выключена до включения. Фиксированный allowlist методов, cap 64 KB / 100 запросов на batch и двухуровневый rate limit ограничивают, что один агент может сделать с нодой. Сам шлюз по умолчанию выключен; faucet, transfer и x402 выключены независимо от него; лимиты переводов по умолчанию нулевые; ненулевые pre-restore ceilings faucet/transfer требуют явного ограниченного по времени одобрения. У x402 отдельно зафиксированы сумма, получатель и срок действия; transfer daily quotas к нему не относятся. Каждое из этих решений оператор принимает отдельно, и ни одно не склоняется по умолчанию в сторону разрешения платежа.

Ключи живут на том же семействе hardware/TEE-подложки, что и остальная подписывающая инфраструктура. Ключи assigned-wallet’ов занимают собственный namespace в keystore 2d-hsm, отдельный от ключей оператора моста. On-chain правила обращаются с assigned wallet как с любым другим аккаунтом: гарантии модели безопасности и state-root применяются к нему без изменений.

Chain 77 — production. Значения по умолчанию в коде, активная конфигурация и проведённые recovery-церемонии — отдельные факты. Перед изменением funding или signing policy проверьте развёрнутый релиз, подписанные restore evidence и текущий signer canary. Эта статья не подтверждает проведение или отсутствие конкретной restore-церемонии.

Публичная регистрация никогда не даёт faucet. Capability transfer позволяет и transfers, и x402 authorize, когда это разрешают их отдельные переключатели и политики. Потеря токена может лишить доступа к кошельку: сохраните токен до пополнения. Назначенные ключи не переиспользуются после таймаута или отключения аккаунта.

Шлюз это потребительская поверхность поверх той же цепочки, что описана в остальной документации. В статье Адреса объяснён 20-байтовый аккаунт, в который разрешается assigned wallet. Модель безопасности описывает границы доверия, за которыми стоит шлюз. В HSM-топологии разобран отдельный подписывающий путь оператора моста; 2d-hsm анклав, которым пользуется шлюз, это иной подписант того же hardware-семейства, а долгосрочное направление для этого анклава это дизайн PQ-подписи в TEE.