
EIP-712 — это стандарт для хеширования и подписи типизированных данных. Основная цель заключается в улучшение опыта пользователя, позволяя кошелькам показывать «человекочитаемые» данные подписи.
Стандарт является разновидностью ERC-191. Согласно этому стандарту подпись формируется следующим образом:
0x19 — говорит о том, что подпись используется в сети Ethereum и не является совместимой с RLP кодировкой, которая применяется для кодирования данных транзакций.
1 byte version — говорит о типе подписи: personal, EIP-712 и так далее.
Для EIP-712 поля описываются следующим образом:
-
<1 byte version>=0x01. Указывает на то, что в подписи будет использоваться стандарт EIP-712. Все возможные типы версий описаны в таблице стандарта ERC-191. -
<version specific data>=domainSeparator. ТерминdomainSeparatorвводится стандартом EIP-712 и служит для описания особенностей контекста подписи. -
<data to sign>=hashStruct(message). Это хеш подписываемых пользователем данных.
Кодирование domainSeparator
DomainSeparator является уникальным идентификатором контекста подписи и имеет три глобальные цели:
-
Идентификация контекста – включает в себя данные, чтобы гарантировать уникальность подписи.
-
Защита от повторного использования – если пользователь подписал данные для конкретного протокола, то они не могут быть действительными для другого протокола или сети.
-
Оптимизация хеширования – domainSeparator позволяет заранее вычислить часть хэша всей подписи, что ускоряет проверку подписи.
Описывается domainSeparator следующим образом:
domainSeparator = hashStruct(eip712Domain)
hashStruct(eip712Domain) — это хеш структуры, которая содержит следующие поля:
-
string name. Название протокола, в котором будет использоваться подпись -
string version. Текущая версия домена подписи. Подписи из разных версий несовместимы. По сути это инструмент для версионирования подписей. -
uint256 chainId. Идентификатор цепочки. Используется EIP-155 для защиты от replay attack. Особенно необходимо, когда протокол работает в нескольких сетях. -
address verifyingContract. Адрес контракта, который будет проверять подпись. Используется для того, чтобы ограничить список проверяющих подпись. -
bytes32 salt. Соль для устранения неоднозначности протокола. Запасной вариант, который может использоваться для разграничения двух подписей с одинаковыми данными domainSeparator.
Важно! Все поля структуры eip712Domain опциональны и должны быть описаны разработчиками в случае необходимости.
Кодирование данных подписи
Этот раздел описывает кодирование того, что в рамках EIP-191 мы определили, как <data to sign>, а в рамках EIP-712 как hashStruct(message).
Но прежде, чем разбирать хеш структурированных данных необходимо понимать какие в принципе типы данных могут быть.
Типы данных
Выделяют всего три типа данных:
-
Атомарные типы. Это
bytes1,bytes32,uint8,uint256,int8,int256и так далее. Такжеboolиaddress. Важно, что не используются aliasesint,uint. Также стандарт оставляет возможность добавления новых типов в будущем. -
Динамические типы. Сюда относятся
bytesиstring. -
Ссылочные типы. Это массивы и структуры. Массивы с динамическим размером обозначаются, как
Type[], c фиксированным размеромType[n]. Например,address[]илиaddress[5].
Важно знать и учитывать типы данных, потому что для некоторых из них есть нюансы при кодировании и проверке подписи на смарт-контрактах.
Хеширование данных подписи
Посмотрим, что из себя представляет hashStruct(message).
hashStruct(message) = keccak256(typeHash ‖ encodeData(message))
Эту запись можно понять так, что хешируется при помощи keccak256 два объекта: typeHash и encodeData(message).
typeHash — это константа, которая описывает хеш типов данных из message. Описать математически это можно следующим образом typeHash = keccak256(encodeType(typeOf(message))).
encodeData(message) — это кодирование полей структуры данных message.
В коде мы будем описывать TYPE_HASH хешем строки, которая описывает типы поля user для абстрактной структуры Order:
bytes32 private constant TYPE_HASH = keccak256("Order(address user)");
В роли message выступает значение адреса user, которое мы будем кодировать.
encodeData
Можно воспринимать это, как функцию, которая конкатенирует закодированные поля структуры message в том порядке, в котором они объявлены.
Важно! Каждое закодированное значение имеет длину ровно 32 байта.
И вот здесь мы подошли к кодированию полей, которое зависит от типа самого поля.
Значения атомарного типа
Кодируются атомарные типы соответственно ABI v1 и v2. То есть bool кодируется, как число uint256 в значениях 0 или 1. Адреса кодируются как uint160. И так далее. Больше подробностей в документации Solidity.
При проверке подписи на смарт-контрактах не требуется дополнительно кодировать эти типы данных.
Значения динамического типа
Кодируются, как хеш контента при помощи функции keccak256(). keccak256 — принимает набор байт и это означает, что чтобы хешировать строку, необходимо сначала строку преобразовать в bytes при помощи функции abi.encodePacked().
При проверки подписи на смарт-контрактах, нам придется дополнительно кодировать эти данные.
// string string memory str = "test"; bytes32 encodedStr = keccak256(abi.encodePacked(str)); // bytes bytes memory strInBytes = "test"; bytes32 encodedStrInBytes = keccak256(strInBytes);
Значения ссылочного типа
Массивы кодируются, как хеш конкатенированных значений массива. А структура, кодируется рекурсивно, как hashStruct(message). Сложно для понимания, но это тот случай когда в структуре данных на подпись есть дочерняя структура и дочерняя структура будет кодироваться по тем же правилам, что и родительская.
// array[2] address[] memory addressArray = new address[](2); addressArray[0] = address(0); addressArray[1] = address(0); bytes32 encodedAddressArray = keccak256(abi.encodePacked(addressArray)); // struct bytes32 encodedStruct = keccak256(abi.encode( PARENT_TYPE_HASH, // nested structure keccak256(abi.encode( CHILD_TYPE_HASH, // ... nested structure fields )), // ... parent fields ));
Проверка подписи
В этом разделе посмотрим на примеры обработки подписи на смарт-контракте в разных ситуациях.
Для этого напишем эталонный пример смарт-контракта с использованием OpenZeppelin.
// SPDX-License-Identifier: MIT pragma solidity 0.8.26; import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; contract SignatureChecker is EIP712 { // encode typeHash bytes32 private constant TYPE_HASH = keccak256("Order(address user)"); struct Args { bytes signature; } // Inherit by EIP712(name, version) constructor() EIP712("EIP-712 based on OZ", "1") {} function checkSignature(Args calldata args) public view returns (bool) { // encode message bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( TYPE_HASH, msg.sender ))); // recover signer and check address signer = ECDSA.recover(digest, args.signature); if (signer != msg.sender) { return false; } return true; } }
Этот простой пример проверяет, что вызывающий функцию checkSignature(), подписал собственный адрес акаунта, как структуру данных в сообщении.
Функция _hashTypedDataV4() хеширует согласно EIP-712 encodedData (мы сами кодируем эти значения) и domainSeparator (устанавливается в конструкторе) вместе.
Дальше будем смотреть фишки при организации проверки подписи на базе эталонного смарт-контракта.
Повторное использование
Если подпись должна использоваться единоразово, то не должно быть возможности использовать ее повторно.
Самый распространенный пример такой подписи — это подпись, которая позволяет распоряжаться активами пользователя. Предположим пользователь создает ордер на покупку актива и подписывает сумму, которую он готов потратить на покупку. Подпись передается протоколу, протокол должен использовать подпись и списать сумму оплаты в момент передачи актива пользователю.
В этом примере важно, чтобы никто не смог воспользоваться подписью несколько раз и произвести списание указанной суммы. Для того, чтобы этого не произошло в тело подписи вводится одноразовый счетчик nonce, который делает подпись уникальной. После использования подписи на смарт-контракте счетчик увеличивается.
contract SignatureChecker is EIP712 { // Add nonce to TYPE_HASH bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint256 nonce)"); struct Args { bytes signature; } mapping(address user => uint256 nonce) public nonces; constructor() EIP712("EIP-712 based on OZ", "1") {} function checkSignature(Args calldata args) public returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( TYPE_HASH, msg.sender, nonces[msg.sender] + 1 // Add next user nonce ))); address signer = ECDSA.recover(digest, args.signature); if (signer != msg.sender) { return false; } // increase user nonce nonces[msg.sender] += 1; return true; } }
Смарт-контракт всегда ожидает, что nonce в подписи будет определен согласно счетчику, который увеличивается сразу после использования подписи. Такой нехитрый алгоритм не позволяет повторно использовать подпись и делает ее одноразовой.
Использование в разных сетях, протоколах, смарт-контрактах
Подпись, данная пользователем в одной сети, не должна быть использована в другой.
Подпись, данная пользователем для одного смарт-контракта, не должна быть использована в другом.
Часто протоколы работают в нескольких EVM совместимых сетях. Совместимость позволяет переиспользовать код смарт-контрактов. Для того, чтобы подпись не могла быть использована в другой сети в состав полей domainSeparator вводится поле chainId.
Если мы используем смарт-контракт EIP-712 от OpenZeppelin, то нам не нужно переживать за chainId, если мы используем собственное решение, то должны учитывать это поле. Помним, что поле опциональное, и если протокол работает в одной сети, то поле может быть опущено.
Однако чаще всего поле всегда добавляется, так как протокол может захотеть опубликоваться в новой сети позже, а обновить контракты в старой сети и добавить поле chainId не всегда возможно из-за не изменяемости смарт-контрактов.
В смарт-контракте EIP-712 от OpenZeppelin видно, что в составе domain находится chainId по умолчанию.
abstract contract EIP712 is IERC5267 { ... function _buildDomainSeparator() private view returns (bytes32) { return keccak256(abi.encode(TYPE_HASH, _hashedName, _hashedVersion, block.chainid, address(this))); } ... }
Аналогично chainId поля _hashedName иaddress(this) регламентируют использование подписи только в конкретном протоколе или смарт-контракте, где реализован алгоритм проверки подписи.
Использование в разных функциях одного смарт-контракта
Подпись, данная пользователем для проверки в одной функции смарт-контракта, не должна быть использована в другой, за исключением, когда это действительно необходимо.
Для регламентирования использования подписи в одной функции смарт-контракта лучший вариант делать уникальными подписываемые данные. Если поля данных одинаковые, то они могут быть разными семантически. Ниже пример формирования двух TYPE_HASH для функций deposit() и withdraw().
contract SignatureChecker is EIP712 { bytes32 private constant DEPOSIT_TYPE_HASH = keccak256("DEPOSIT(address user,uint256 nonce)"); bytes32 private constant WITHDRAW_TYPE_HASH = keccak256("WITHDRAW(address user,uint256 nonce)"); ... function deposit(Args calldata args) public returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( DEPOSIT_TYPE_HASH, msg.sender, nonces[msg.sender] + 1 ))); ... return true; } function withdraw(Args calldata args) public returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( WITHDRAW_TYPE_HASH, msg.sender, nonces[msg.sender] + 1 ))); ... return true; } }
Есть еще один вариант решения. В domain предусмотрено поле salt, которое может быть использована для уникальности домена подписи.
Однако смарт-контракт EIP-712 от OpenZeppelin не предоставляет возможность работать с этим полем из коробки. Для использования salt и OpenZeppelin вместе придется переопределять функции смарт-контракта EIP-712 вручную.
Использование времени жизни
Подпись, данная пользователем, может быть использована в рамках конкретного временного отрезка.
Иногда стоит задача реализовать время жизни подписи, чтобы по истечению времени она перестала быть валидной. Решается это путем добавления в тело подписи timestamp, который обозначает время окончания валидности подписи. При этом на смарт-контракте необходимо добавить проверку, что это время еще не наступило.
contract SignatureChecker is EIP712 { // Add expiredTime to TYPE_HASH bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint64 expiredTime,uint256 nonce)"); // Add new field expiredTime struct Args { bytes signature; uint256 expiredTime; } ... // Add error error ExpiredTime(); function checkSignature(Args calldata args) public returns (bool) { // Check time if (block.timestamp >= args.expiredTime) { revert ExpiredTime(); } bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( TYPE_HASH, msg.sender, args.expiredTime, // Add expiredTime nonces[msg.sender] + 1 ))); ... return true; } }
Отмена подписи
Возможность отменить подпись.
Часто подпись используется в качестве разрешения действия от имени пользователя. Более того, пользователь подписывает сообщение и передает подпись на хранение протоколу. В таком случае он остается без возможности отменить действие.
Исправить это достаточно легко. На базе nonce необходимо реализовать отмену подписи. Для этого достаточно увеличить nonce. Если nonce еще нет в составе подписи, тогда необходимо его туда добавить.
contract SignatureChecker is EIP712 { mapping (address user => uint256 nonce) public nonces; ... function cancelSignature() external { nonces[msg.sender] += 1; emit SignatureCanceled(msg.sender); } }
Пользователю достаточно самостоятельно вызвать функцию cancelSignature(), что увеличит nonce и сделает выданную ранее подпись автоматически невалидной.
Топ ошибок на смарт-контрактах
В этом разделе разберем типовые ошибки, которые возникают у разработчиков при разработке смарт-контрактов, реализующих проверку подписи. Ошибки взяты с ресурса Solodit.
Все эти проблемы могут быть не выявлены в ходе написания тестов с использованием Foundry. Так как тестирование подписей, как и другие тесты пишутся на solidity и результат может быть неосознанно подогнан под ошибку.
В этом плане Hardhat дает больше надежности, потому что там для тестирования подписи приходится написать максимально приближенный к реальности код на js.
Пропуск TYPE_HASH
Несоблюдение стандарта.
Иногда разработчики забывают указать TYPE_HASH для структуры данных или считают, что он не является важным.
contract SignatureChecker is EIP712 { ... function checkSignature(Args calldata args) public returns (bool) { if (block.timestamp >= args.expiredTime) { revert ExpiredTime(); } // There is no TYPE_HASH in the signature structure bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( msg.sender, args.expiredTime, // Added expiredTime field nonces[msg.sender] + 1 ))); ... return true; }
Не использование поля TYPE_HASH приводит к несовместимости со стандартом EIP-712, что влечет за собой невозможность использовать классические инструменты для создания подписи (metamask sdk и так далее). Также не является безопасным.
Пропуск полей описанных в TYPE_HASH при кодировании
Любого рода ошибки при формировании TYPE_HASH или полей подписываемых данных.
В ходе разработки часто бывает много изменений, требующих особого внимания. Работа с подписью не является исключением. Типичная ошибка, когда структура данных разработчиком меняется, но он забывает исправить TYPE_HASH для этой структуры.
contract SignatureChecker is EIP712 { // Forgot to correct TYPE_HASH. You need to add expiredTime field bytes32 private constant TYPE_HASH = keccak256("Order(address user,uint256 nonce)"); ... function checkSignature(Args calldata args) public returns (bool) { ... bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( TYPE_HASH, msg.sender, args.expiredTime, // Added expiredTime field nonces[msg.sender] + 1 ))); ... return true; }
Было добавлено поле expiredTime в тело подписи, но забыли добавить поле в TYPE_HASH. Такие ошибки часто встречаются. Может быть ситуация наоборот, когда TYPE_HASH обновлен, а структура данных нет.
В большинстве случаев эту проблему можно избежать качественно выполняя работу по тестированию смарт-контракта.
Ошибка кодирования динамических типов
Динамические типы должны кодироваться особым способом.
Часто новенькие в разработке смарт-контрактов, но возможно бывалые разработчики из других технологий упускают важность правильного кодирования динамических типов.
Динамические типы кодируются, как хеш значения при помощи keccak256().
contract SignatureChecker is EIP712 { bytes32 private constant TYPE_HASH = keccak256("Order(address operator,address token,uint256 amount,uint256 nonce,bytes data,string str)"); ... constructor() EIP712("EIP-712 based on OZ", "1") {} function checkSignature(SigArgs calldata args) public view returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( TYPE_HASH, msg.sender, // operator args.token, args.amount, _nonces[args.user] + 1, // args.data и args.str - incorrect // For encode dynamic types you need use keccak256 keccak256(args.data), keccak256(abi.encodePacked(args.str)) ))); ... return true; } }
keccak256() принимает на вход bytes, поэтому строку сначала необходимо перевести в bytes при помощи abi.encodePacked().
Ошибка кодирования ссылочных типов
Ссылочные типы должны кодироваться особым способом.
Это подобная ошибка, как и с динамическими типами, только касается массивов и структур данных.
В кодировании массивов разработчики часто забывают, что это должен быть хеш от набора байт всех элементов массива keccak256(abi.encodePacked(array)). Забывают или keccak256 или abi.encodePacked, или все вместе.
contract SignatureChecker is EIP712 { bytes32 private constant ORDER_TYPE_HASH = keccak256("Order(address operator,address[] tokens,uint256[] amounts,uint256 nonce)"); ... function checkSignature(SigArgs calldata args) public view returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( ORDER_TYPE_HASH, msg.sender, // Error using args.tokens without keccak256(abi.encodePacked()) keccak256(abi.encodePacked(args.tokens)), keccak256(abi.encodePacked(args.amounts)), _nonces[args.user] + 1 ))); ... return true; } }
Для вложенных друг в друга структур часто допускают ошибки в описании TYPE_HASH. В примере ниже показан правильный вариант описания структур OPERATOR_TYPE_HASH и ORDER_TYPE_HASH.
contract SignatureChecker is EIP712 { bytes32 private constant OPERATOR_TYPE_HASH = keccak256("Operator(address operator,string name)"); bytes32 private constant ORDER_TYPE_HASH = keccak256("Order(Operator operator,address token,uint256 amount,uint256 nonce)Operator(address operator,string name)"); ... function checkSignature(SigArgs calldata args) public view returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( ORDER_TYPE_HASH, keccak256(abi.encode( // We encode separately all fields of the nested Operator structure OPERATOR_TYPE_HASH, // Don't forget about TYPE_HASH for the operator structure msg.sender, keccak256(abi.encodePacked(args.operatorName)) // Don't forget about dynamic types )), args.token, args.amount, _nonces[args.user] + 1 ))); ... } }
Использование abi.encode вместо abi.encodePacked для генерации TYPE_HASH
Необходимо с осторожностью конкатенировать различные TYPE_HASH.
В примере с вложенными структурами можно заметить дублирование OPERATOR_TYPE_HASH внутри ORDER_TYPE_HASH.
Поэтому некоторые протоколы оптимизируют расчет TYPE_HASH, особенно, когда необходимо подписать вложенные структуры данных. Посмотрим на новом примере структур данных.
bytes memory itemTypeString = abi.encodePacked( "Item(uint8 itemType,address token,uint256 identifier)" ); bytes memory orderTypeString = abi.encodePacked( "Order(Item item,address user)" );
Для получения итогового TYPE_HASH нельзя использовать abi.encode, необходимо использовать abi.encodePacked.
bytes32 TYPE_HASH = keccak256( - abi.encode(itemTypeString, orderTypeString) + abi.encodePacked(itemTypeString, orderTypeString) ); +
В отличие от abi.encodePacked, abi.encode добавляет нулевые байты, чтобы всегда возвращать данные в формате 32-х байт, таким образом результирующие хеши не будут одинаковыми.
Для проверки я накидал пример, можно проверить его в remix.
contract Test { function matchTypeHashes() external pure returns (bytes32, bytes32, bytes32) { bytes memory itemTypeString = abi.encodePacked( "Item(uint8 itemType,address token,uint256 identifier)" ); bytes memory orderTypeString = abi.encodePacked( "Order(Item item,address user)" ); return ( keccak256(abi.encode(itemTypeString, orderTypeString)), keccak256(abi.encodePacked(itemTypeString, orderTypeString)), keccak256("Item(uint8 itemType,address token,uint256 identifier)Order(Item item,address user)") ); } }
Среди возвращаемых значений третий хеш будет совпадать со вторым, первый будет отличаться. Согласно EIP-712 второй и третий — это правильные варианты.
Подпись не защищена от повторного использования
Зачастую подпись не должна иметь возможность быть использованной повторно.
Мы уже разбирали эту ошибку косвенно в разделе от чего подпись должна быть защищена, но к сожалению, ошибка повторного использования продолжает нередко встречаться в смарт-контрактах.
Решается введением счетчика nonce для каждой подписи или deadline параметра, регламентирующего время валидности подписи.
Подпись может быть перехвачена и использована другим адресом
Нужно четко определять, кто может воспользоваться подписью.
Структура данных подписи не включает поле аккаунта, который может использовать эту подпись.
Атака может быть проведена по типу front-run. Слушается мемпул, как только оригинальная транзакция, подписанная пользователем, попадает в мемпул, злоумышленник копирует данные подписи и вызывает от своего имени.
contract SignatureChecker is EIP712 { bytes32 private constant TYPE_HASH = keccak256("Order(address[] tokens,uint256[] amounts,uint256 nonce)"); struct SigArgs { address user; address[] tokens; uint256[] amounts; bytes signature; } ... function checkSignature(SigArgs calldata args) public view returns (bool) { bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( // There is no msg.sender in the signature structure TYPE_HASH, keccak256(abi.encodePacked(args.tokens)), keccak256(abi.encodePacked(args.amounts)), _nonces[args.user] + 1 ))); address signer = ECDSA.recover(digest, args.signature); if (signer != args.user) { return false; } // send tokens to msg.sender return true; } }
Интересный факт
Даже у таких передовых специалистов из области безопасности, как разработчики из OpenZeppelin, бывают факапы.
До версии 4.7.3 в смарт-контрактах OpenZeppelin оставлена уязвимость связанная с обработкой подписи.
Функции ECDSA.recover()и ECDSA.tryRecover(). Затронуты были перегрузки, которые принимали набор bytes за место v, r, s параметров. Пользователь мог взять подпись, которая уже была отправлена, отправить ее снова в другой форме и обойти проверку.
Вывод
EIP-712 предоставляет мощный механизм защиты от атак повторного воспроизведения, делает подписи человекочитаемыми и безопасными. Однако реализация этого стандарта требует строгого соблюдения правил кодирования.
Основные выводы:
-
Использование domainSeparator регламентирует использование подписей в разных контекстах.
-
Кодирование данных требует особого внимания к данным динамических и ссылочных типов, чтобы избежать проблем с валидацией подписей.
-
Включение nonce предотвращает повторное использование подписей, что особенно важно для финансовых операций.
Отступление от стандарта может привести к уязвимостям, позволяющим злоумышленникам использовать подписи повторно или в нежелательных контекстах.
Разработчикам рекомендуется тщательно тестировать реализацию EIP-712 и использовать проверенные библиотеки (например, OpenZeppelin).
Links
Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.
ссылка на оригинал статьи https://habr.com/ru/articles/918648/
Добавить комментарий