Ethereum’s ecrecover(), OpenZeppelin’s ECDSA, and web3’s sign().

Yaoshiang Ho
May 16 · 5 min read

This article talks about some of the tricks of dealing with Ethereum’s ecrecover() function.

This is a changeup from the more business oriented articles I’ve written in the past. My non-technical audience can ignore this.

TLDR: Only use ecrecover(), web3.eth.hashMessage(), and openzeppelin.ECDSA.toEthSignedMessageHash() on fully padded 32 byte numbers representing the hash of your actual message. Other scenarios lead to inconsistent behavior between the various implementations.

BACKGROUND:

Ethereum and Bitcoin are based on ECDSA encryption, a type of public-key cryptography. People my age grew up on RSA, another type of public-key cryptography. The math itself is not the important part —what is important is that it’s possible to verify that a message is really coming from someone.

Solidity provides the verification component with the ecrecover() function. But Solidity doesn’t support signing, because the Ethereum blockchain is not designed to keep secrets. Signing must occur outside Ethereum, typically using web3 tools. And this is where the mismatches begin.

ISSUE ONE: Prepending

There are multiple levels of message you need to be aware of.

Actual Message: The thing you care about, such a string like “This is Alice’s message” or the number 17 encoded as one byte, 0x11.

Actual Message’s Hash: Partly because Solidity has poor string manipulation, OpenZeppelin strongly suggests that you hash your Actual Message, and, operate solely on the Message Hash. You do this in solidity with the simple

keccak256(abi.encodePacked(…)).

Wrapped Message: web3’s signing tools (web3.eth.sign and web3.accounts.sign) automatically implement a safety feature: before a message is signed, the string “\x19EthereumSignedMessage\n…” + length of the message is prepended to form a Wrapped Message. This is to prevent a user from accidentally signing what she thinks is a plain old message, but is in fact an encoded transaction to be run on Ethereum. web3 allows you to wrap any arbitrary string, but, that is a bad idea because it does not match what OpenZeppelin does.

There’s also a subtler issue here — Solidity has incomplete string manipulation tools, so, it’s actually not possible to generate a string containing the base10 numerals that represent the length of the abitrary string. OpenZeppelin’s ECDSA expects a bytes32 so it hard codes 32:

function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {
// 32 is the length in bytes of hash,
// enforced by the type signature above
return keccak256(abi.encodePacked(“\x19Ethereum Signed Message:\n32”, hash));
}

whereas web3.Accounts.hashMessage allows arbitrary input:

https://github.com/ethereum/web3.js/blob/98e3a1012556ccd60957735f248279053c035295/packages/web3-eth-accounts/src/Accounts.js

hashMessage(data) {

if (this.utils.isHexStrict(data)) {

data = this.utils.hexToBytes(data);

}

const messageBuffer = Buffer.from(data);

const preambleBuffer = Buffer.from(`\u0019Ethereum Signed Message:\n${data.length}`);

const ethMessage = Buffer.concat([preambleBuffer, messageBuffer]);

return Hash.keccak256s(ethMessage);

}

Also, web3 has the “helpful” polymorphism that if you pass web3.eth.sign anything other than a hex encoded string, it will interpret it as a string, but if you pass a hex encoded string, it will interpret it as a byte array. Note that padding matters (I think). 0x0001 and 0x01 are the same number, but they are not the same byte array [0x00, 0x01] versus [0x01]. Net net: Ignore the option of sending arbitrary string. Stick with passing a 66 character string representing a 32 byte array, e.g.:. ‘0x1111111111111111111111111111111111111111111111111111111111111111’

Wrapped Message’s Hash. The Wrapped Message is hashed into 32 bytes. This is the second time hashes are used. This can make it pretty confusing when looking at the OpenZeppelin.ECDSA library. The param is named hash in the two function, but, represent very different things.

OpenZeppelin.ECDSA.toEthSignedMessage(bytes32 hash) is the Actual Message’s Hash.

OpenZeppelin.ECDSA.recover(bytes32 hash…) expects the Wrapped Message’s Hash.

Solidity’s ecrecover() operates on the Wrapped Message’s Hash. Actually, it just operates on any 32 bytes, but, web3 and OpenZeppelin enforce the wrapping and hashing that transforms your Actual Message to a Wrapped Message’s Hash.

web3’s two sign() functions and one recover() function all operate at the level of the Actual Message. They handle consistently handle the wrapping and hashing.

But the more frequent case would be to ask web3 to sign and Solidity’s ecrecover() to verify. In that case, the toolsets are not consistent. You should make web3 only work on the Actual Message’s Hash, and, when dealing with ecrecover(), call web3.eth.accounts.hashMessage() or Open Zeppelin’s ECDSA.toEthSignedMessage() on the Actual Message’s Hash to form the Wrapped Message’s Hash.

ISSUE TWO: SIGNATURES:

web3.eth.sign() returns a 65 byte array, which is the concat of the 32 byte r value, the 32 byte s value, and the one byte v value. (R, S, and V are the components of an ECDSA signature).

web3.eth.accounts.sign() returns an object that contains r, s, and v as a bytes32, bytes32, and a uint8. Why are these two web3 interfaces inconsistent? I dunno but it’s annoying. There’s a good reason to have both functions… web3.eth.accounts.sign() allows you to operate on arbitrary private keys, making it much more directly mathematically oriented, whereas web3.eth.sign() operates on addresses it knows how to access via RPC (thanks Francisco Giordano) from ganache / GoEth, requiring key lookup.

OpenZeppelin expects a 65 byte array so if you are using the more common web3.eth.sign(), you are fine.

Unfortunately solidity considers bytes(65) to be a variable length byte array and if you need to pass an array of signatures, you are out of luck. You can’t do any varriable arrays of variable arrays as params, such as a bytes[]. So I broke up the signatures into an array of Rs, array of Ss, and an array of Vs. You can do the slicing with basic Javascript string manipulation.

r1 = ‘0x’ + sig.slice(2, 64+2)
s1 = ‘0x’ + sig.slice(64+2, 128+2)
v1 = ‘0x’ + sig.slice(128+2, 130+2)

ISSUE THREE: V VALUES

I don’t understand the math here, but, web3.eth.sign() that calls Ganache can generate 0 and 1 instead of 27 and 28. I’m not sure if this alos happens with GETH. OpenZeppelin.ECDSA’s source discusses the meaning of this further, but net net, you need to add 27 to your V value. Or you can do it the brute force way like this. (0x1b is obviously 27 in decimal).

if (v1 == ‘0x00’) {
v1 = ‘0x1b’
} else if (v1 == ‘0x01’) {
v1 = ‘0x1c’
}

https://github.com/OpenZeppelin/openzeppelin-solidity/blob/master/contracts/cryptography/ECDSA.sol

// EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature

// unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines

// the valid range for s in (281): 0 < s < secp256k1n ÷ 2 + 1, and for v in (282): v ∈ {27, 28}. Most

// signatures from current libraries generate a unique signature with an s-value in the lower half order.

//

//

If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 — s1 and flip v from 27 to 28 or

// vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept

// these malleable signatures as well.

    Yaoshiang Ho

    Written by

    Trying to make decentralization happen.