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

Веб-кошелёк monero: digest-авторизация, точные суммы и безопасная отправка транзакций

Веб-кошелёк Monero на официальном monero-wallet-rpc: Digest-авторизация, предварительная сборка транзакции и точные суммы

Идея проекта была простой только на первый взгляд: сделать локальный веб-кошелёк Monero с современным интерфейсом, но не превращать его в кастодиальный сервис и не писать собственную криптографию. Все операции с ключами, балансом и подписанием транзакций должны оставаться внутри официального `monero-wallet-rpc`. Веб-приложению отводилась роль оболочки: бэкенд, интерфейс и инфраструктурная логика.

Такой подход заметно безопаснее самодельной реализации, однако он не избавляет от технических сложностей. Основные проблемы возникли не в React или Express, а на границе между браузером, HTTP-клиентом Node.js и специфическим RPC-сервером Monero.

Проект задумывался с несколькими жёсткими требованиями:

- кошелёк работает локально, на `127.0.0.1`;
- ключи и подпись транзакций обрабатываются только официальными бинарниками Monero;
- браузер не получает RPC-логин и пароль;
- прямой доступ фронтенда к `monero-wallet-rpc` исключён;
- перед отправкой пользователь видит фактическую комиссию;
- при недоступности узла интерфейс показывает ошибку, а не подставной баланс;
- денежные значения обрабатываются без потери точности.

Для реализации были выбраны Node.js, Express и TypeScript на серверной стороне, а также React, Vite и Tailwind CSS для интерфейса. В коде проекта нет собственной криптографии: сервер выступает посредником между браузером и официальным кошельком.

Почему браузеру нельзя доверять RPC-доступ

Теоретически фронтенд мог бы обращаться к `monero-wallet-rpc` напрямую. На практике это плохая идея сразу по нескольким причинам.

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

Поэтому пароль знает только бэкенд. RPC-сервис слушает локальный адрес, а браузер взаимодействует исключительно с сервером приложения. Даже если интерфейс будет скомпрометирован, прямого канала к кошельку у него нет.

HTTP Digest и привязка к TCP-соединению

Первой неожиданностью стала авторизация. Обычный запрос с Basic Auth не подходит: сервер отвечает кодом `401` и выдаёт Digest challenge.

Проблема не ограничивается вычислением правильного MD5-ответа. HTTP-слой Monero использует nonce, связанный с конкретным TCP-соединением. Между тем стандартные клиенты вроде `fetch` и `undici` управляют пулом сокетов самостоятельно. Повторная попытка может уйти уже через другое соединение, и сервер отклонит формально корректный заголовок.

В результате возникает особенно неприятная ошибка: одинаковые запросы с одинаковым паролем то проходят, то возвращают `401`. Поведение зависит от того, какой сокет выбрал клиент.

Дополнительную сложность создаёт формат `WWW-Authenticate`. Сервер может вернуть несколько заголовков, а Node.js объединяет их через запятую. Для корректного разбора требуется взять первый challenge. Если случайно обработать вариант с `algorithm=MD5-sess`, расчёт не совпадёт с тем, что ожидает epee.

Надёжное решение - использовать `node:http`, создать одно keep-alive-соединение и выполнять Digest-обмен в рамках этого же сокета. Важно также формировать итоговый заголовок в ожидаемом порядке и не добавлять параметр `algorithm`, если конкретная реализация Monero его не принимает.

Для диагностики полезно иметь отдельный небольшой скрипт, который выводит challenge, сформированный Digest-заголовок и итоговый код ответа. Такой инструмент быстро показывает, где именно возникает ошибка: при разборе заголовка, вычислении хэша или смене соединения.

Отправка транзакции в два этапа

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

Более честная схема состоит из двух шагов:

1. кошелёк собирает транзакцию, но не публикует её;
2. пользователь проверяет сумму и комиссию, после чего подтверждает отправку.

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

После подтверждения сервер использует сохранённые метаданные для фактической отправки. Такой подход одновременно повышает прозрачность интерфейса и не заставляет пользователя доверять приблизительной комиссии.

Почему нельзя использовать обычный JavaScript Number

Суммы Monero передаются в атомарных единицах и имеют тип `uint64`. Максимальное значение такого типа - 18 446 744 073 709 551 615, тогда как безопасный целочисленный диапазон JavaScript заканчивается на 9 007 199 254 740 991.

Если преобразовать крупное значение в `Number`, а затем вызвать `JSON.stringify`, число может быть незаметно округлено. Для денежных операций это критическая ошибка: интерфейс покажет одно значение, а RPC получит другое.

Поэтому на сервере используются `BigInt`, строковые представления и явное преобразование между Monero и отображаемыми единицами. Параметры RPC формируются так, чтобы атомарная сумма не проходила через `Number`. На клиентской стороне также нельзя бездумно применять арифметику с плавающей точкой.

Корректная схема выглядит так:

- пользователь вводит сумму в XMR;
- приложение разбирает её как десятичную строку;
- значение переводится в атомарные единицы;
- расчёты выполняются через `BigInt`;
- в RPC уходит строковое или безопасно сериализованное целое;
- для интерфейса сумма форматируется обратно без потери точности.

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

Создание кошелька и неожиданное пересканирование

Ещё одна проблема проявляется при создании нового кошелька. После запуска `monero-wallet-rpc` может начать сканировать всю цепочку блоков. Для крупного или медленного узла это занимает значительное время.

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

Полезно показывать отдельные статусы:

- RPC запущен;
- кошелёк открыт;
- идёт сканирование;
- узел синхронизирован частично;
- баланс временно недоступен;
- операция завершена.

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

Медленный узел не должен блокировать интерфейс

Локальный узел Monero может отвечать медленно, особенно во время синхронизации или работы с большим кошельком. Поэтому запросы к RPC нельзя бездумно выполнять в основном пользовательском сценарии.

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

Фронтенд, в свою очередь, должен отображать промежуточные состояния: "проверяем узел", "получаем баланс", "операция выполняется". Это лучше, чем замораживать страницу или показывать устаревшие данные без пояснений.

Жизненный цикл сессии

RPC-сессия кошелька - это не просто один HTTP-запрос. Нужно контролировать запуск процесса, открытие кошелька, состояние соединения, закрытие и аварийное завершение.

При остановке приложения следует корректно закрывать кошелёк и дочерний процесс, если это возможно. Логи не должны содержать seed-фразу, приватные ключи, пароли и полные чувствительные RPC-ответы.

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

Безопасность по умолчанию

Локальная работа не означает автоматическую безопасность. Минимальный набор защитных мер включает:

- привязку сервисов к loopback-интерфейсу;
- отсутствие RPC-пароля в браузере;
- запрет произвольного проксирования RPC-команд;
- фильтрацию и валидацию входных параметров;
- ограничения на сумму и адрес получателя;
- очистку временных метаданных после отправки или отмены;
- безопасную обработку ошибок;
- отсутствие секретов в логах;
- защиту от повторной отправки одной и той же операции.

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

Практические проблемы с бинарниками

На Windows отдельное внимание требуется уделить антивирусной проверке официальных бинарников Monero. Defender может задерживать запуск, помещать файл в карантин или предупреждать о неизвестном приложении.

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

Что ещё предстоит улучшить

У проекта остаются естественные ограничения. Необходимы более подробные интеграционные тесты, проверка восстановления после падения RPC, сценарии прерывания отправки и тестирование на больших кошельках.

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

Главный вывод заключается в том, что сложность такого приложения скрыта не в количестве строк и не в визуальном интерфейсе. Самые опасные ошибки появляются на стыке протоколов, типов данных и состояний системы: Digest зависит от TCP-сокета, комиссия становится известна только после сборки транзакции, а обычный JavaScript `Number` способен испортить денежное значение.

Если оставить криптографию официальному `monero-wallet-rpc`, изолировать RPC от браузера, использовать двухфазную отправку и обрабатывать суммы через `BigInt`, архитектура становится заметно надёжнее. Но даже в таком варианте кошелёк остаётся программой, работающей с реальными средствами, поэтому каждое удобство интерфейса должно сопровождаться проверяемым поведением и безопасными ограничениями.

Прокрутить вверх