Oftentimes, UX and security issues often arise when signing transactions securely. For example, there needs to be an easier way to read transaction data when signing a transaction. To understand signature creation, verification and preventing replay attacks work, we will take a dive into the workings of two Ethereum Improvement Proposals - EIP 191 and EIP 712.
In Ethereum, proof of ownership can be achieved using public and private key pairs to generate signatures. Imagine, appending your signatures on a paper document, it grants ownership of the paper to the signer. This is what digital signatures aim to achieve. These public and private key pairs define Ethereum externally owned accounts (EOAs) and provide a means of interacting with the blockchain by signing and sending transactions, without others being able to access the account they do not own.
Signatures provide a means of cryptographic authentication in the blockchain technology, serving as a unique fingerprint forming the backbone of blockchain transactions. They are used to validate computation performed off-chain and authorize transactions on behalf of a signer.
Creating signatures involves hashing the message and then combining this hash (digest) with the private key using ECDSA algorithm; this process is referred to as signing a message.
Upon signing a message, a signature is created (digital signature) is generated, serving as a means to verify that the signer is indeed the intended account.
Each distinct message produces a unique hash called digest.
This is important when considering replay attacks. To prevent replay attack, each signature has to be unique. This is achieved by including a nonce, a number used once, as part of the message. Most times the creation of digital signatures is abstracted away from the user. However, when implementing signatures into a smart contract, extra data including nonce and chain ID should be added to prevent replay attacks.
function getSignerSimple(
uint256 message,
) public pure returns (address) {
bytes32 digest = bytes32(message);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); // digital signature
address signer = ecrecover(hashedMessage, v, r, s);
return signer;
}
function verifySignerSimple(
uint8 _v,
bytes32 _r,
bytes32 _s,
address signer,
uint256 message,
) public pure returns (bool) {
address actualSigner = getSignerSimple(message, v, r, _s);
require(signer == actualSigner);
return true;
}
This is how signatures used to be constructed for cryptographic authentication purposes. But it comes with a flaw, which is the way it display the data to sign in bytes as it comes with security risk.
The EIP-191, is a signed data standard that proposes the format for signed data. Unlike the signature creation mechanism explained earlier, that only does arbitrary message hashing. It introduces a standard for signing data.
<prefix> | <1 byte version> | <version specific data> | <data to sign>
Prefix (0x19) - This signifies that the data is a signature.
1 byte version - The version associated with the sign data.
0x00 is the version with an intended validator.
0x01 is structured data.
EIP-191 uses 0x00 which is the version with the intended validator.
Data to sign - This is the application specific data or message to sign.
function getSigner191 (
uint256 message,
) public view returns (address) {
// Arguments when calculating hash to validate
// 0x19 < 1 byte version> < version specific data> < data to sign>
// 1: byte(0x19) - the initial 0x19 byte
// 2: byte(0) - the version byte
// 3: version specific data, for version 0, it's the intended validator address
// 4-6 : Application specific data
bytes1 prefix = bytes1(0x19);
bytes1 eip191Version = bytes1(0); // 0x00
address indendedValidatorAddress = address(this);
bytes32 hashMessage = bytes32(message);
bytes32 digest =
keccak256(abi.encodePacked(prefix, eip191Version, indendedValidatorAddress, applicationSpecificData));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
address signer = ecrecover(hashedMessage, v, r, s);
return signer;
}
function verifySigner191(
uint256 message,
uint8 _v,
bytes32 _r,
bytes32 _s,
address signer
)
public view returns (bool) {
address actualSigner = getSigner191(message, v, r, _s);
require(signer == actualSigner);
return true;
}
However if the data to sign is complex, there should be a way to format the data in a readable way, EIP-712.
Since EIP-191 wasn’t specific enough, the data to sign needed to be easier read and displayed. This EIP proposes a standard for hashing, structuring and signing typed data. Improving ux interaction, readability and specificity to certain contracts. The primary goal is to provide a way to prevent users from misusing signatures, allowing developers to define a clear structure for the data to sign. Due to the inefficiencies of EIP-191, there needed to be a way to send transactions using premade signatures: sponsored transactions.
This standard has a format for signed data:
Prefix 0x19
Version 0x01
DomainSeparator - the hash of the data associated with the version, the domain struct defining the domain of the message being signed.
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
// bytes32 salt; not required
}
EIP712Domain eip_712_domain_separator_struct = EIP712Domain({
name: "SignatureVerifier", // this can be anything
version: "1", // this can be anything
chainId: 1, // ideally the chainId
verifyingContract: address(this) // ideally, set this as "this", but can be any contract to verify signatures
});
function getSignerEIP712(
uint256 message,
) public view returns (address) {
// Prepare data for hashing
bytes1 prefix = bytes1(0x19);
bytes1 eip712Version = bytes1(0x01); // EIP-712 is version 1 of EIP-191
struct Message {
uint256 number;
uint256 nonce; // inserting nonce into every signature prevents replay attacks
}
bytes32 public constant MESSAGE_TYPEHASH = keccak256("Message(uint256 number)");
bytes32 digest = keccak256(abi.encode(MESSAGE_TYPEHASH, Message({ number: message })));
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
return ecrecover(digest, v, r, s);
}
}
function verifySignerEIP712(
uint256 message,
uint8 _v,
bytes32 _r,
bytes32 _s,
address signer
) public view returns (bool) {
address actualSigner = getSignerEIP712(message, v, r, _s);
require(signer == actualSigner);
return true;
}
It's recommended to use OpenZeppelin libraries as the standard and more secure way to integrate EIP-712 for signature creation and hashing. It comes with the function: _hashTypedDataV4
Create the message type hash and hash it with the message data:
bytes32 public constant MESSAGE_TYPEHASH = keccak256("Message(uint256 message)");
function _hashTypedDataV4(bytes32 hashData) public view returns(bytes32 digest) {
return keccak256(
abi.encodePacked(
"\x19\x01",
domainSeparator,
hashData
)
)
}
function getMessageHash(Message message) public view returns (bytes32) {
return _hashTypedDataV4(
keccak256(
abi.encode(
MESSAGE_TYPEHASH,
message.number,
message.nonce
)
)
);
}
Retrieve the signer with ECDSA.tryRecover and compare it to the actual signer:
function getSignerOZ(
uint256 digest,
uint8 v,
bytes32 r,
bytes32 _s
) public pure returns (address) {
(address signer, /* ECDSA.RecoverError recoverError /, / bytes32 signatureLength */ ) = ECDSA.tryRecover(digest, v, r, _s);
return signer;
}
function verifySignerOZ(
uint256 message,
uint8 _v,
bytes32 _r,
bytes32 _s,
address signer
) public pure returns (bool) {
address actualSigner = getSignerOZ(getMessageHash(message), v, r, _s);
require(actualSigner == signer);
return true;
}
As mentioned earlier, EIP-712 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:
Have every signature have a unique nonce that is validated
Set and check an expiration date
Restrict the s value to a single half
Include a chain ID to prevent cross-chain replay attacks
Any other unique identifiers (for example, if there are multiple objects to sign in the same contract/chain/etc).
Earlier we mentioned the ux issues solved by EIP-712, and that signatures can constructed off-chain which helps in gas optimization.
Whenever you integrate EIP-712 into a project, how do you construct the signatures off-chain? This section focuses on constructing off-chain signatures using ether.js. We will be working with this project: Airdrop Contract.
import { ethers } from 'ethers';
async function generateSignature(userAddress, amount, merkleAirdropContractAddress) {
// 1. Connect to the user's wallet
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// 2. Prepare the domain and types for EIP-712 signature
const domain = {
name: "MerkleAirdrop",
version: "1",
chainId: await signer.getChainId(),
verifyingContract: merkleAirdropContractAddress
};
const types = {
AirdropClaim: [
{ name: "account", type: "address" },
{ name: "amount", type: "uint256" }
]
};
// 3. Create the value object
const value = {
account: userAddress,
amount: amount
};
// 4. Request the signature from the user's wallet
try {
const signature = await signer.signTypedData(domain, types, value);
return signature;
} catch (error) {
console.error("Error signing typed data:", error);
throw error;
}
}
Once you have the signature, you'll need to split it into its components (v, r, s) before calling the claim
function:
async function claimAirdrop(userAddress, amount, merkleProof, signature) {
// Split the signature into v, r, s components
const sig = ethers.Signature.from(signature);
// Call the contract
const tx = await merkleAirdropContract.claim(
userAddress,
amount,
merkleProof,
sig.v,
sig.r,
sig.s
);
return tx;
}
The above steps and code explained how to construct off-chain signatures in a smart contract that integrates EIP-712.
EIP-712 is a valuable tool for Ethereum developers looking to enhance security and user experience in their dApps. By signing structured data, you reduce the risk of signature misuse and provide clarity to users about what they are signing. As you build and deploy your applications, consider integrating EIP-712 to ensure a safer and more user-friendly experience.
paul elisha