Подписи: доказать, не отправляя транзакцию
- Зачем это нужно
- Что такое подпись и что она доказывает
- Подпись не «ломается» — она указывает на другого
- Что подписывается на самом деле
- Типизированные данные: подписывать структуру, а не текст
- Домен: где подпись действительна
- Контракт проверяет подпись сам
- Повторное использование
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Транзакция стоит газа, занимает блок и оставляет след навсегда. Но часто нужно совсем другое: доказать, что ты — это ты, или заранее разрешить кому-то что-то сделать.
Вход в интерфейс без пароля. Разрешение на вывод, которое действует до определённого срока. Подтверждение согласия с условиями. Голос в опросе, который считают вне сети.
Всё это делает подпись — бесплатно, мгновенно и без единой транзакции. А проверить её может и сервер, и другой человек, и сам контракт.
Что такое подпись и что она доказывает
Подпись связывает конкретные данные с конкретным ключом. Из неё нельзя достать ключ, но можно достать адрес того, кто подписал.
── подпись обычного сообщения ──
адрес подписавшего : 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
сообщение : Вход в хранилище. Одноразовый код: 8f3a2c
подпись : 0xf3177e2d085844d5c35a0e54d591592c…fe8bad1c
длина подписи : 65 байт
из чего состоит : r = 0xf3177e2d08…, s = 0x121502cbc9…, v = 28
── проверка: кто подписал ──
восстановленный адрес: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
совпал с подписавшим : true
Шестьдесят пять байт: две половины по тридцать два и один байт-признак, помогающий восстановить адрес однозначно.
const sig = await signer.signMessage("Вход в хранилище. Одноразовый код: 8f3a2c");
const who = verifyMessage(message, sig); // адрес
Подпись не «ломается» — она указывает на другого
Самая важная мысль главы. Меняем в сообщении один символ и проверяем ту же подпись:
── подменили один символ в сообщении ──
сообщение : Вход в хранилище. Одноразовый код: 8f3a2d
восстановленный : 0xA7Fd9B48937e3e2c7aABE3cF9698993682262c10
совпал с подписавшим: false
(подпись не сломалась — просто восстановился ДРУГОЙ адрес)
Проверка подписи — это всегда сравнение. Восстановили адрес и сверили с тем, кого ждали. Если сравнения нет, проверки нет вообще: восстановление само по себе никогда не «не удаётся».
Отсюда классическая уязвимость: код, который восстанавливает адрес и не сравнивает его ни с чем, пропускает любую подпись.
В контракте это выглядит особенно опасно: восстановление при неверной подписи может вернуть нулевой адрес. Если где-то в хранилище нулевой адрес — допустимое значение, сравнение случайно сойдётся. Отсюда правило: сначала проверить, что восстановленный адрес не нулевой.
Что подписывается на самом деле
── что на самом деле подписывается ──
сообщение оборачивают в префикс и хешируют:
\x19Ethereum Signed Message:\n<длина><сообщение>
хеш этого: 0xafaeb283f653c9ad705b709c96b6260006d1f74db2087fa5562882e4b8a62915
Подписывается не текст, а хеш текста с приставкой. Приставка нужна не для красоты: без неё злоумышленник мог бы подсунуть на подпись готовую транзакцию под видом безобидного сообщения — и получить подписанный перевод своих денег.
Приставка делает подписанное сообщение заведомо не-транзакцией. Это первая линия защиты, и она встроена в сам метод подписи сообщений.
Типизированные данные: подписывать структуру, а не текст
Подпись текста годится для входа. Для разрешения на вывод она плоха: человек видит в кошельке набор байтов и соглашается, не понимая.
── типизированные данные: подписывают структуру ──
что видит пользователь в кошельке — поля с именами:
owner address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
amount uint256 2500000000000000000
deadline uint256 1789000000
и отдельно — откуда это:
{"name":"Vault","version":"1","chainId":1337,
"verifyingContract":"0x8464135c8F25Da09e49BC8782676a84730C318bC"}
const domain = { name: "Vault", version: "1", chainId, verifyingContract };
const types = {
Withdraw: [
{ name: "owner", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const sig = await signer.signTypedData(domain, types, value);
Домен: где подпись действительна
Проверяем ту же подпись с изменённым доменом:
── та же подпись, но проверяем в ДРУГОЙ сети ──
chainId 1 вместо 1337 → восстановлен 0xBEaA9b037Ba65b2516e040E28d91a04a11BDDc91
совпал: false
── и у другого контракта ──
другой адрес контракта → совпал: false
── и с изменённой суммой ──
сумма 25 вместо 2.5 → совпал: false
Три разные попытки использовать подпись не по назначению — и все три не проходят. Это и есть смысл домена: подпись привязана к приложению, версии, сети и контракту.
Зачем это нужно: у приложений бывают копии в тестовой сети, у контрактов — новые версии. Без домена подпись, данная в тестовой сети, работала бы в настоящей.
Контракт проверяет подпись сам
Самое интересное: тот же хеш умеет посчитать и контракт.
bytes32 public constant WITHDRAW_TYPEHASH =
keccak256("Withdraw(address owner,uint256 amount,uint256 deadline)");
function digest(address owner, uint256 amount, uint256 deadline) public view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(WITHDRAW_TYPEHASH, owner, amount, deadline));
return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash));
}
function check(address owner, uint256 amount, uint256 deadline, bytes calldata sig)
external view returns (bool)
{
if (block.timestamp > deadline) revert Expired();
(bytes32 r, bytes32 s, uint8 v) = _split(sig);
address recovered = ecrecover(digest(owner, amount, deadline), v, r, s);
if (recovered != owner) revert BadSignature(recovered);
return true;
}
── хеш, который считают обе стороны ──
в библиотеке : 0x4ffef390753c2b7fe98b96c40ef744cd9043dcf6fc2c6457f2cb15a3202471ea
в контракте : 0x4ffef390753c2b7fe98b96c40ef744cd9043dcf6fc2c6457f2cb15a3202471ea
── проверка в контракте ──
подпись верна : true
сумма изменена на 25 : BadSignature — восстановлен 0xAB299B23952190AE9220D72819BF181D92BcBb28
срок истёк : Expired
Это способ переложить работу на пользователя. Разрешение подписывает владелец — бесплатно; транзакцию с этим разрешением отправляет кто угодно — и платит за газ он же. Так работают одобрения токенов по подписи и целые сервисы «отправки за вас».
Повторное использование
У подписи есть свойство, о котором забывают: она работает сколько угодно раз. Тот, кому вы дали разрешение на вывод двух с половиной эфиров, может предъявить его контракту дважды — и, если контракт не помнит, что уже принимал эту подпись, деньги уйдут дважды.
Контракт из прогона намеренно упрощён: в нём этого нет. В настоящем добавляют одно из двух:
- счётчик у владельца. Он входит в подписываемую структуру и увеличивается при каждом использовании. Старая подпись перестаёт подходить автоматически.
- список использованных подписей. Хеш структуры записывают в хранилище; повторное предъявление отклоняют.
Первое дешевле и принято в стандартах одобрений. Второе проще понять.
Срок действия — обязателен в любом случае. Подпись без срока живёт вечно; найденная через год, она всё ещё работает.
Типичные ошибки
Восстановили адрес и не сравнили. Проверки нет вовсе: пройдёт любая подпись.
Не проверили на нулевой адрес. Неверная подпись даёт ноль, и если ноль где-то допустим, проверка сойдётся.
Нет срока действия. Разрешение живёт вечно.
Нет защиты от повторного использования. Одну подпись предъявляют дважды.
Подписывают текст там, где нужна структура. Человек не понимает, что подтверждает, — и подписывает разрешение, думая, что входит на сайт.
Считают хеш структуры руками в браузере. Библиотека делает это по стандарту; самодельный расчёт разойдётся с контрактом на первом же поле с длинной строкой.
Домен без номера сети или адреса контракта. Подпись становится переносимой между сетями и версиями.
Порядок полей в описании типа и в контракте разный. Хеши не совпадут, и виноватым будет выглядеть кошелёк.
Слова главы
- Подпись — 65 байт, связывающих данные с ключом; ключ из неё не достать.
- Восстановление адреса — из подписи и данных получается адрес; неверные данные дают другой адрес, а не ошибку.
- Проверка — сравнение восстановленного адреса с ожидаемым; без сравнения проверки нет.
- Приставка — помечает подписанное как не-транзакцию.
- Типизированные данные — подпись структуры с именами полей; человек видит, что подтверждает.
- Домен — имя, версия, сеть, контракт; где подпись действительна.
- Проверка в контракте — тот же хеш, встроенное восстановление, сравнение.
- Защита от повторов — счётчик или список использованных; плюс обязательный срок.
Тренажёр: набрать вслепую
Цель: скорость ≥ 90 зн/мин, точность ≥ 88%
const sig = await signer.signMessage(message);
На чемпионате
Вход по подписи вместо пароля. Одна кнопка, окно кошелька, одноразовый код в сообщении — и вы доказали, что владеете адресом. Бесплатно и за секунду.
Разрешение по подписи — сильный ход на защите. Показать, что владелец подписал разрешение, а транзакцию отправил другой человек, — это выглядит как понимание предмета, а не как выполненный пункт.
Срок и защита от повторов — обязательно. Первое же, о чём спросят, если вы делаете подпись в контракте.
Покажите сравнение хешей. Один и тот же хеш, посчитанный в браузере и в контракте, — самое наглядное доказательство того, что вы понимаете, как это устроено.
И про безопасность: объясняйте пользователю, что он подписывает. Половина краж в этой области — не взломы, а подписи, которые человек поставил не глядя.
Куда углубиться
- Одобрение токенов по подписи — стандарт, где разрешение даётся без транзакции.
- Готовая библиотека проверки подписей — учитывает крайние случаи, которые легко пропустить.
- Подписи контрактов-кошельков — как проверить подпись того, у кого нет приватного ключа.
- Вход по подписи с описанием — стандарт человекочитаемого сообщения для входа.
- Отправка за пользователя — кто платит за газ и как это устроено.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- EIP-191: подпись данных — откуда взялась приставка.
- EIP-712: типизированные данные — домен, типы, правила хеширования.
- ethers: подписи — подписать и проверить.
- OpenZeppelin: ECDSA и EIP712 — готовые средства проверки в контракте.
- EIP-2612: одобрение по подписи — разрешение на перевод токенов без транзакции.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.