EIP712 authentication in Cairo

Jakub Szmurło
Software Mansion
Published in
8 min readDec 15, 2022

--

In this article, I would like to show you how to verify messages signed as described in EIP712 on StarkNet. This requires recreating the original EIP712 payload, hashing it, and verifying the Ethereum signature. To demonstrate that, we’ll create a simple smart contract that will bind Ethereum addresses and StarkNet addresses together, proving ownership. You can find every bit of code that I’ve put in here in this repository, alongside some additional functions and a python script, that serves as an Ethereum signing service.

Background

Make sure that you’re familiar with the Cairo language, as it is essential in this case. Some knowledge of the EIP-712 standard would be nice too, as I won't be going into too much detail about it in this article.

What is EIP-712

EIP-712 is a standard for hashing and signing typed structured data. Since the signing function in the Ethereum environment uses byte strings that are linear for both input and output, there is no natural way of signing more complex data structures, widely present in some more complicated projects. A function transforming a structure to a byte string is non-trivial, because we need to ensure determinism, pre-image resistance, and collision resistance, and depending on the implementation, some of those criteria might not be met.

EIP-712 does exactly that: it gives us a clear way to create a deterministic function from almost any type of structure to byte strings, which can then be signed with a private key. To understand the rest of the article you will need to understand how this standard handles hashing structures and what a domain separator is. I recommend skimming through this proposal, to get a basic grasp of the topic.

Use case — creating a binding between ETH address and Starknet Address

Message structure

This is the structure that we’ll be using in this example. Note that the domain here represents eip712Domain which, after hashing, will become the domain separator that we need.
Elements of the domain are as follows:

  • name - the user-readable name of the signing domain, i.e. the name of the DApp or the protocol.
  • contractAddress - the address of the contract that will verify the signature. It ensures that the signature can only be used by our contract on the given chain.
  • version - the current major version of the signing domain. Signatures of messages using different versions are not compatible.

Payload will be the main structure that we'll be constructing and signing. For the sake of simplicity, it only has one field but the EIP-712 standard offers a lot of flexibility.

Signing with an Ethereum wallet

Let’s use Metamask as an example. You just need to call the signTypedData_v4 function, which is a part of Metamask’s API, with proper parameters and it will return the signature we need.

After you run this JS code in the browser’s console with MetaMask enabled, it will trigger a signing request. An application would use a similar code to get the user’s account and signature.

MetaMask signature request notifi

As you can see everything is transparent, so you don’t have to sign something blindly — that’s one of the benefits of using the EIP-712 standard.

When you sign this message you will receive the message signature that we need. After that, we can send a transaction containing both the Ethereum address and the signature of a StarkNet contract.

Creating a connection

Before we bind Ethereum & StarkNet addresses we need to ensure that the caller owns both addresses. This is how you can do it:

  1. Sign the structure containing the StarkNet address with your Ethereum account.
  2. Call the verifier contract that we are creating with the received signature and your Ethereum address using YOUR StarkNet account (you will see why it is crucial in the next paragraph).
  3. Recreate the message structure and hash it with keccak256.
  4. Extract the Ethereum address from the Ethereum signature and the hash from the previous step.
  5. Compare the Ethereum address received as an input to the address extracted from the step before.

The hardest part for us is getting the hash of our message. Once we have that, we can use a built-in function verify_eth_signature_uint256 that combines steps 4 and 5 and will do all the heavy lifting, dealing with complicated math. So we will have to write a function that constructs an eip712 structure and returns its hash.

Recreating and hashing the message

The basic idea

Since the ownership of the Ethereum account is confirmed by signing the message for which the private key is needed, we also need to confirm that the user owns the StarkNet account that is supposed to be connected. That’s why we can’t accept the StarkNet address as a parameter and have to use syscall get_caller_address to get it, which means that we must use our StarkNet account for calling the contract. The connection will be created only if the address of the account executing the tx is the one that has been included in the signed message.

Constants

Instead of calculating the structure hash every time, we can precalculate it and store it as a constant to save us some computationally expensive operations. Additionally, Ethereum uses a pre-chosen prefix, which is explained in the proposal.

Because we use keccak256 for hashing, we need to store the result in two pieces (lower and higher 128 bits) as felts store only up to 251 bits.

We could do the same with the domain separator, but there is one small obstacle — we’ll be using this contact address as a variable in the domain, and hardcoding this value would affect the resulting hash, changing the value itself. So how can we deal with it? We certainly don’t need to calculate it every time the function is called but instead, we can just create a storage variable that can only be set once and put our precalculated domain separator there, after contract deployment.

An account deploying mapping contract must execute set_domain_separator within the same transaction as deployment, using multicall to guarantee that the domain separator is set before anyone can call the contract.

Recreating the message hash

Since our example is very simple, our structure contains only one field (starknetAddress). Recreating the hashable string is pretty easy — we just need to concatenate the structure hash with our value.

There is a small problem — the 2-byte prefix, which prevents us from using the keccak_uint256s_bigend function. We need to add the prefix first. Since the domain hash and data hash are both 256-bit long we are storing them in 128-bit chunks in 4 felts. Because of this small prefix we need to shift everything by 16 bits (2 bytes) and this is what add_prefix does, it takes the initial value and the prefix and outputs our result and the 16-bit overflow which will be used as the prefix for the next value.

Before adding prefix to our data
After adding prefix to our data

We can’t use the keccak_uint256s_bigendfunction, since it assumes that our input in bytes is a multiple of 32, which it is not. Because of the 2 overflow bytes, our input is 66 bytes long, and thus keccak_uint256s_bigend will assume that we're also counting all the leading zeros of the last value, making our input 96 bytes long, which would result in incorrect hashes. The solution is to use the low-level keccak function - keccak_bigend, which unfortunately takes the input in the little-endian format, forcing us to convert inputs into little-endian. Note that the bigend in the name of the function refers to the output format, not the input format.

Now we can finally write our get_eip712_hash function. First, we need to hash our data using the constant TYPE_HASH and our StarkNet address which combined with the domain hash completes our message. Now we need to add the prefix, shifting every variable so we can finally change the input to a little-endian format and hash it with keccak.

Since the Ethereum signature is 66 bytes long, we are storing it in 5 felts, each having a maximum of 16 bytes so we can combine them in pairs into Uint256. After getting the message hash from the get_eip712_hash function and splitting our signature into v, r, and s parts, all that's left to do is call verify_eth_signature_uint256.

It’s VERY important that we call finalize_keccak because of the internal batching done inside it. Failing to call it, will make the keccak function unsound - the prover will be able to choose any value as the keccak's result.

Finalizing

If everything matches, no assertion fails and the save_connected_addresses function will be called, which will bind our two addresses together.

The last thing to do is create a connection between eth_address and starknet_address. As this is an example, we'll be using a basic map between a pair of addresses and a boolean value (the read function of a storage variable in Cairo will return 0 if a key doesn't exist). In our simple case save_connected_addresses just calls an internal write function of the storage variable.

Bear in mind that we won’t be able to list all the Starknet addresses connected to the Ethereum address or vice versa. All we can do is check whether some two addresses are connected or not. If you want some more advanced functions, you’d need to change how the addresses are bound — for example, instead of
func storage(eth_address : felt, starknet_address : felt) -> (exists: felt) as a storage variable, you could use
func storage(eth_address : felt, starknet_address_index : felt) -> (starknet_address: felt) which, instead of mapping a pair of eth_address and starknet_address to a 1, marking the existence of the connection maps the pair of eth_address and index to the starknet_address allowing you to iterate through them:

storage(0x1, 0) = 0x321312
storage(0x1, 1) = 0x721397
storage(0x1, 2) = 0 (not set, there are 2 bindings for address 0x1)

Check this repository if you want to see the code come together. Mind that the code has not been audited and should not be used on production without a proper audit. It is only meant to be a demonstration of the concept.

--

--