Перейти к основному содержимому
Блокчейн52ГЛАВА 52Подписи: доказать, не отправляя транзакциюowneramountdeadlinesign65 байтecrecover → ownerбез транзакции

Подписи: доказать, не отправляя транзакцию

Прочитано0%
Квизы0/4
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Транзакция стоит газа, занимает блок и оставляет след навсегда. Но часто нужно совсем другое: доказать, что ты — это ты, или заранее разрешить кому-то что-то сделать.

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

Всё это делает подпись — бесплатно, мгновенно и без единой транзакции. А проверить её может и сервер, и другой человек, и сам контракт.

Что такое подпись и что она доказывает​

Подпись связывает конкретные данные с конкретным ключом. Из неё нельзя достать ключ, но можно достать адрес того, кто подписал.

── подпись обычного сообщения ──
адрес подписавшего : 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
(подпись не сломалась — просто восстановился ДРУГОЙ адрес)
ПОДПИСЬ НЕ «ЛОМАЕТСЯ» — ОНА ВОССТАНАВЛИВАЕТ ДРУГОЙ АДРЕСто же сообщение, что подписывали«…Одноразовый код: 8f3a2c»восстановлен 0xf39Fd6e5…Fb92266совпал с подписавшимзначит, подпись подлиннаяпоменяли один символ«…Одноразовый код: 8f3a2d»восстановлен 0xA7Fd9B48…62262c10не совпал ни с кем знакомымошибки при этом не былопроверка — это всегда сравнение: восстановили адрес и сверили с тем, кого ждали65 байт подписи: две половины по 32 байта и один байт-признак
Ошибки не было: из тех же 65 байт и другого текста восстановился совершенно другой адрес

Проверка подписи — это всегда сравнение. Восстановили адрес и сверили с тем, кого ждали. Если сравнения нет, проверки нет вообще: восстановление само по себе никогда не «не удаётся».

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

Важно

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

Отвечено вопросов: 0 из 2
Что происходит, если проверять подпись против изменённого сообщения?
Можно ли достать приватный ключ из подписи?
Отвечено верно: 0 из 2

Что подписывается на самом деле​

── что на самом деле подписывается ──
сообщение оборачивают в префикс и хешируют:
\x19Ethereum Signed Message:\n<длина><сообщение>
хеш этого: 0xafaeb283f653c9ad705b709c96b6260006d1f74db2087fa5562882e4b8a62915

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

Приставка делает подписанное сообщение заведомо не-транзакцией. Это первая линия защиты, и она встроена в сам метод подписи сообщений.

Типизированные данные: подписывать структуру, а не текст​

Подпись текста годится для входа. Для разрешения на вывод она плоха: человек видит в кошельке набор байтов и соглашается, не понимая.

── типизированные данные: подписывают структуру ──
что видит пользователь в кошельке — поля с именами:
owner address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
amount uint256 2500000000000000000
deadline uint256 1789000000
и отдельно — откуда это:
{"name":"Vault","version":"1","chainId":1337,
"verifyingContract":"0x8464135c8F25Da09e49BC8782676a84730C318bC"}
ЧТО ВИДИТ ЧЕЛОВЕК В ОКНЕ КОШЕЛЬКАподпись текста0x7b226f776e6572223a22307866…набор байтов или непонятная строкасогласиться, не понимая, — нормаименно так и крадут разрешенияподпись структурыowner 0xf39Fd6e5…amount 2500000000000000000deadline 1789000000поля с именами и типамиплюс домен: имя, версия, сеть, контрактдомен отвечает на вопрос «где эта подпись действительна»: другая сеть или другой контракт — уже нетпроверено: тот же подписанный текст в сети 1 вместо 1337 восстановил чужой адрес
Слева — то, что человек не может проверить; справа — поля с именами и домен
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
ОДИН И ТОТ ЖЕ ХЕШ СЧИТАЮТ ОБЕ СТОРОНЫв браузере, библиотекой0x4ffef390753c2b7fe98b96c40ef744cd9043dcf6fc2c6457f2cb15a3202471eaв контракте, на Solidity0x4ffef390753c2b7fe98b96c40ef744cd9043dcf6fc2c6457f2cb15a3202471eaсумма изменена на 25 → BadSignature, восстановлен 0xAB299B23…срок истёк → Expiredподпись, сделанную в браузере, контракт проверяет сам — и не тратит на это ни одной транзакции
Библиотека в браузере и контракт в сети посчитали один и тот же хеш до последнего байта
Нажми на часть выражения

Это способ переложить работу на пользователя. Разрешение подписывает владелец — бесплатно; транзакцию с этим разрешением отправляет кто угодно — и платит за газ он же. Так работают одобрения токенов по подписи и целые сервисы «отправки за вас».

Отвечено вопросов: 0 из 2
Что даёт домен в типизированных данных?
Встроенное восстановление адреса при неверной подписи возвращает…
Отвечено верно: 0 из 2

Повторное использование​

У подписи есть свойство, о котором забывают: она работает сколько угодно раз. Тот, кому вы дали разрешение на вывод двух с половиной эфиров, может предъявить его контракту дважды — и, если контракт не помнит, что уже принимал эту подпись, деньги уйдут дважды.

Контракт из прогона намеренно упрощён: в нём этого нет. В настоящем добавляют одно из двух:

  • счётчик у владельца. Он входит в подписываемую структуру и увеличивается при каждом использовании. Старая подпись перестаёт подходить автоматически.
  • список использованных подписей. Хеш структуры записывают в хранилище; повторное предъявление отклоняют.

Первое дешевле и принято в стандартах одобрений. Второе проще понять.

Срок действия — обязателен в любом случае. Подпись без срока живёт вечно; найденная через год, она всё ещё работает.

Типичные ошибки​

Восстановили адрес и не сравнили. Проверки нет вовсе: пройдёт любая подпись.

Не проверили на нулевой адрес. Неверная подпись даёт ноль, и если ноль где-то допустим, проверка сойдётся.

Нет срока действия. Разрешение живёт вечно.

Нет защиты от повторного использования. Одну подпись предъявляют дважды.

Подписывают текст там, где нужна структура. Человек не понимает, что подтверждает, — и подписывает разрешение, думая, что входит на сайт.

Считают хеш структуры руками в браузере. Библиотека делает это по стандарту; самодельный расчёт разойдётся с контрактом на первом же поле с длинной строкой.

Домен без номера сети или адреса контракта. Подпись становится переносимой между сетями и версиями.

Порядок полей в описании типа и в контракте разный. Хеши не совпадут, и виноватым будет выглядеть кошелёк.

Отвечено вопросов: 0 из 2
Почему подписи нужен срок действия и защита от повторного использования?
Зачем подписывать разрешение, если можно просто отправить транзакцию?
Отвечено верно: 0 из 2

Слова главы​

  • Подпись — 65 байт, связывающих данные с ключом; ключ из неё не достать.
  • Восстановление адреса — из подписи и данных получается адрес; неверные данные дают другой адрес, а не ошибку.
  • Проверка — сравнение восстановленного адреса с ожидаемым; без сравнения проверки нет.
  • Приставка — помечает подписанное как не-транзакцию.
  • Типизированные данные — подпись структуры с именами полей; человек видит, что подтверждает.
  • Домен — имя, версия, сеть, контракт; где подпись действительна.
  • Проверка в контракте — тот же хеш, встроенное восстановление, сравнение.
  • Защита от повторов — счётчик или список использованных; плюс обязательный срок.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 90 зн/мин, точность ≥ 88%

const sig = await signer.signMessage(message);

На чемпионате​

Вход по подписи вместо пароля. Одна кнопка, окно кошелька, одноразовый код в сообщении — и вы доказали, что владеете адресом. Бесплатно и за секунду.

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

Срок и защита от повторов — обязательно. Первое же, о чём спросят, если вы делаете подпись в контракте.

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

И про безопасность: объясняйте пользователю, что он подписывает. Половина краж в этой области — не взломы, а подписи, которые человек поставил не глядя.

Куда углубиться​

  • Одобрение токенов по подписи — стандарт, где разрешение даётся без транзакции.
  • Готовая библиотека проверки подписей — учитывает крайние случаи, которые легко пропустить.
  • Подписи контрактов-кошельков — как проверить подпись того, у кого нет приватного ключа.
  • Вход по подписи с описанием — стандарт человекочитаемого сообщения для входа.
  • Отправка за пользователя — кто платит за газ и как это устроено.

Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоSolidity и Ethereum, урок #40 | ecrecover, подпись оффчейн/проверка подписи, платежи без газаIlya Krukowski
ВидеоSolidity и смарт-контракты Ethereum, урок #48 | ERC2612, ERC20Permit, аппрув без газа, EIP712Ilya Krukowski
ВидеоРешаем тестовую задачу. Разбираемся с оффчейн подписями r, v ,s и ecrecover. #solidity #уязвимиостиDima HideCrypto - Web3 | Solidity
Видео1011 Цифровая подпись и верификацияProgramming Innopolis STEM
ВидеоVerify Signature | Solidity 0.8Smart Contract Programmer

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.