To understand how signature creation, verification, and preventing replay attacks work, the Ethereum Improvement Proposals EIP-191 and EIP-712 need to be understood first.
When signing transactions, there needed to be an easier way to read transaction data. For example, before these standards were created, the following message was displayed when signing a transaction in MetaMask:
Image: Unstructured message before EIP712 when signing transaction in MetaMask
These standards meant that transactions could be displayed in a readable way:
Image: Structured message using EIP-712 when signing transaction in MetaMask
Additionally, EIP-712 is key to preventing replay attacks - the data to prevent replay attacks is encoded inside the structured data.
This article outlines these standards, their motivations, and how to implement them.
— The full source code for this article was written by Patrick Collins and can be viewed on GitHub.
This ECDSA Signatures article is recommended for understanding the fundamentals of the first two concepts.
— Note that the code in this article is for demonstrative purposes and has not undergone a thorough security review, do not use it as production code.
For simple signatures, implementing a verification function into a smart contract involves creating a function getSimpleSigner()
which takes a message to sign (which can be any data) and the (r, s, v)
components of the signature, The function hashes the message and retrieves the signer, using the precompile ecrecover
, and returns the result:
ecrecover
is a precompile, a function that is built into the Ethereum protocol, that retrieves the signer from any message using the (r, s, v)
components of the signature.
Then, the function verifySignerSimple()
compares the retrieved signer to an expected signer and reverts if the result is not the expected signer:
This is how signatures work on a fundamental level: take some hashed message plus the signature of the message, retrieve the signer, and check that it’s the address that was expected.
There was an issue with this though, there needed to be a way to send transactions using pre-made signatures: sponsored transactions. This was already possible outside of smart contracts however there needed to be a way to build this into functions in smart contracts. For example, Bob signs a message (a transaction) and gives the signature to Alice. Alice uses this signature to send the transaction meaning that Bob can pay for her gas fees. So, EIP-191 was introduced
The EIP-191 Signed Data Standard proposed the following format for signed data: 0x19 <1 byte version> <version specific data> <data to sign>
0x19
: The prefix.0x19
was chosen because it is not used in any other context.<1 byte version>
: The version of “signed data” is used.0x00
: Data with the intended validator.0x01
: Structures data - most often used in production apps and associated with EIP-712, discussed in the next section.0x02
: personal_sign
messages.<data to sign>
: The message intended to be signed.The following getSigner191()
function demonstrates how to set up an EIP-191 signature:
As observed, retrieving the signer is more verbose using this standard.
The signer can then be compared with the expected signer as before:
However, what if the <data to sign>
is more complicated? There needed to be a way to format the data so that it could be more easily understood. Therefore, the data format needed to be standardized, and EIP-712 was introduced.
EIP-191 was not specific enough and the application (version) specific data needed to be standardized. This meant that signatures could be easier to read and displayed from inside wallets e.g. MetaMask and prevents replay attacks.
EIP-712 introduced standardized data: typed structured data hashing and signing.
The signature now has the following structure:
0x19 0x01 <domainSeparator> <hashStruct(message)>
0x19
: The prefix (from before)0x01
: the version<domainSeparator>
: This is the data associated with the version.eip712Domain
:
This means that contracts can know whether the signature was created specifically for themselves or not. Knowing this, the EIP-712 data can be rewritten as:
0x19 0x01 <hashStruct(eip712Domain)> <hashStruct(message)>
But, what is a hash struct?
The symbolic definition of a hash struct is
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
where typeHash = keccak256(encodeType(typeOf(s)))
A hash struct is a hash of a struct and includes:
hash of what the struct looks like - the typehash. A typehash is a hash of the struct (the type). For the <domainSeparator>
the typehash is:
A hash of the data. For the domain separator, that data is the eip721Domain
struct data:
Putting this together, the <domainSeparator>
becomes:
<hashStruct(message)>
: a hash struct of the message to sign.Using the previous definition of a hash struct, define the typehash:
Then, <hashStruct(message)>
becomes:
The EIP-712 data can be thought of as:
0x19 0x01 <hash of who verifies this signature, and what the verifier looks like> < hash of signed structured message, and what the signature looks like>
Putting this all together, the get signer function becomes:
The signer can then be verified by comparing it to the expected signer as before:
As mentioned earlier, EIP712 is key to preventing replay attacks.
understanding EIP-191 and EIP-712 is important for understanding how to create replay-resistant data to sign into a signature. The extra data in the structure of EIP-712 ensures replay resistance.
To prevent replay attacks, smart contracts must:
s
value to a single half— For more information on signature replay attacks and how to prevent them, refer to this comprehensive guide.
In this guide about EIP-191 and EIP-712, you've learned how Ethereum signature standards work. To summarize:
In order to fully understand signature creation, verification, and signature replay, it is imperative to understand these two standards. This understanding is the key to writing secure smart contracts.