К основному содержимому

Введите слово из вопроса или названия статьи.

3 · Под капотом

Подписи в Ethereum: ECDSA, ecrecover, personal_sign и EIP-712

Как устроена подпись (r, s, v), как контракт проверяет её через ecrecover, чем различаются personal_sign и EIP-712, что такое permit и EIP-1271.

3 мин чтенияОбновлено:
В этой статье

В статье криптография с открытым ключом мы узнали, что подпись доказывает авторство. Теперь посмотрим, как она устроена в Ethereum, как её проверяет контракт и какие ловушки подстерегают разработчика и пользователя.

Формат подписи

Подпись ECDSA на кривой secp256k1 состоит из трёх значений:

  • r - 32 байта;
  • s - 32 байта;
  • v - 1 байт (27 или 28; иногда 0 или 1) - «подсказка», какой из двух возможных открытых ключей имелся в виду.

Всего 65 байт. Подписывается всегда хеш сообщения (32 байта), а не само сообщение.

ecrecover: восстановление подписанта

В EVM есть встроенная функция (прекомпилят по адресу 0x01) ecrecover(hash, v, r, s), которая по хешу и подписи возвращает адрес подписавшего:

solidity
address signer = ecrecover(digest, v, r, s);
require(signer != address(0) && signer == expectedSigner, "bad signature");

Важно: при неверной подписи ecrecover возвращает нулевой адрес, а не ошибку. Если не проверить != address(0), а ожидаемый подписант по ошибке окажется нулевым, подпись «пройдёт». Поэтому в реальных контрактах используют проверенные библиотеки (например, ECDSA из OpenZeppelin).

Ловушка: изменяемость подписи

Для каждой подписи (r, s) существует «зеркальная» (r, n - s), тоже валидная. Это делает подпись изменяемой (malleable): злоумышленник может получить другую валидную подпись, не зная ключа. Если контракт использует хеш подписи как идентификатор («эта подпись уже использована»), такой приём обходит защиту. Решение: принимать только «нижнюю» половину s (требование EIP-2) и защищаться от повтора по nonce, а не по хешу подписи.

personal_sign: безопасная обёртка для сообщений

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

text
"\x19Ethereum Signed Message:\n" + длина + сообщение

Подписывается хеш от этой строки. Префикс гарантирует, что подпись нельзя использовать как подпись транзакции. Так работает вход через кошелёк («Sign-In with Ethereum»).

Минус: пользователь видит набор символов или простой текст и не может увидеть структуру данных.

EIP-712: структурированные данные

EIP-712 решает проблему «слепой подписи». Подписываемая структура имеет тип и поля, и кошелёк показывает их читаемо: получатель, сумма, срок.

Хеш для подписи строится так:

text
digest = keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))

Составные части:

  • hashStruct - хеш данных с учётом типа структуры.
  • domainSeparator - хеш «домена»: name, version, chainId, verifyingContract.

Домен нужен, чтобы подпись была привязана к конкретному приложению и сети. Подпись, сделанная для контракта A на сети 1, не пройдёт для контракта B или для той же копии на сети 8453. Это защита от межсетевого и межконтрактного повтора (почему chainId важен).

Защита от повтора внутри контракта

Даже с доменом ту же подпись можно прислать дважды. Поэтому в подписываемых данных обычно есть:

  • nonce - порядковый номер, который контракт увеличивает после использования;
  • deadline - срок годности подписи.

Контракт проверяет их, и повтор невозможен.

Пример: permit (EIP-2612)

Обычно перевод токенов контракту требует двух транзакций: approve, затем вызов (см. approve). Токены со стандартом permit позволяют сделать это одной: владелец подписывает сообщение EIP-712 Permit(owner, spender, value, nonce, deadline), а контракт передаёт подпись токену, и разрешение выдаётся без отдельной транзакции от владельца.

solidity
token.permit(owner, spender, value, deadline, v, r, s);
token.transferFrom(owner, address(this), value);

Удобно, но и опасно: мошенник может уговорить подписать Permit, и тогда токены можно списать без единой транзакции от вас (атаки). Смотрите в окне кошелька, кому и сколько разрешено.

EIP-1271: подписи контрактных кошельков

У смарт-кошелька (например, мультиподписи) нет закрытого ключа, поэтому ecrecover для него не работает. Стандарт EIP-1271 вводит функцию isValidSignature(hash, signature), которую вызывают на адресе-контракте: если она вернёт магическое значение 0x1626ba7e, подпись считается верной. Контракты, принимающие подписи, должны поддерживать оба способа: ecrecover для обычных адресов и EIP-1271 для контрактов.

Мета-транзакции

Подпись позволяет одному человеку (релееру) отправить в сеть транзакцию от имени другого и заплатить газ. Пользователь лишь подписывает намерение. Это основа «бесгазовых» операций и абстракции аккаунтов.

Ключевые выводы

  1. Подпись всегда относится к хешу; важно, что именно хешируется.
  2. Используйте EIP-712: читаемая структура + привязка к домену.
  3. Всегда включайте nonce и deadline, проверяйте chainId.
  4. Не доверяйте голому ecrecover: проверяйте нулевой адрес и форму s.
  5. Пользователь должен читать, что подписывает.

Что почитать дальше

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

Упоминается в статьях

Готовы попробовать?

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