Что скрывается за EIP-712: подробный разбор стандарта

от автора

EIP-712 — это стандарт для хеширования и подписи типизированных данных. Основная цель заключается в улучшение опыта пользователя, позволяя кошелькам показывать «человекочитаемые» данные подписи.

Стандарт является разновидностью ERC-191. Согласно этому стандарту подпись формируется следующим образом:

Составные части подписи согласно ERC-191

Составные части подписи согласно 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 является уникальным идентификатором контекста подписи и имеет три глобальные цели:

  1. Идентификация контекста – включает в себя данные, чтобы гарантировать уникальность подписи.

  2. Защита от повторного использования – если пользователь подписал данные для конкретного протокола, то они не могут быть действительными для другого протокола или сети.

  3. Оптимизация хеширования – domainSeparator позволяет заранее вычислить часть хэша всей подписи, что ускоряет проверку подписи.

Описывается domainSeparator следующим образом:

domainSeparator = hashStruct(eip712Domain)

hashStruct(eip712Domain) — это хеш структуры, которая содержит следующие поля:

Составные части domainSeparator для EIP-712

Составные части domainSeparator для EIP-712
  • 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).

Но прежде, чем разбирать хеш структурированных данных необходимо понимать какие в принципе типы данных могут быть.

Типы данных

Выделяют всего три типа данных:

  1. Атомарные типы. Это bytes1bytes32uint8uint256int8int256 и так далее. Также bool и address. Важно, что не используются aliases intuint. Также стандарт оставляет возможность добавления новых типов в будущем.

  2. Динамические типы. Сюда относятся bytes и string.

  3. Ссылочные типы. Это массивы и структуры. Массивы с динамическим размером обозначаются, как 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.encodePackedabi.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

  1. EIP-712: Typed structured data hashing and signing

  2. ERC-191: Signed Data Standard

  3. EIP-155: Simple replay attack protection

  4. Solodit

Мы с коллегами периодически пишем в нашем Telegram-канале. Иногда это просто мысли вслух, иногда какие-то наблюдения с проектной практики. Не всегда всё оформляем в статьи, иногда проще написать пост в телегу. Так что, если интересно, что у нас в работе и что обсуждаем, можете заглянуть.


ссылка на оригинал статьи https://habr.com/ru/articles/918648/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *