Skip to content

Instantly share code, notes, and snippets.

@kalaspuff
Last active June 21, 2023 11:37
Show Gist options
  • Save kalaspuff/19365e21e01929c79d5d2638c1ee580e to your computer and use it in GitHub Desktop.
Save kalaspuff/19365e21e01929c79d5d2638c1ee580e to your computer and use it in GitHub Desktop.
Validating Ledger signatures relayed via MetaMask by implementing a simple function that corrects the {v} value of an invalid vrs signature

Validating Ledger signatures relayed via MetaMask

Find the full article at https://mirror.xyz/coa.eth/mvPbLPXvy375CXi1_XwMTzG84lwlSKYUBHDx_R1TIgU, which includes some additional context, descriptions of symptoms, etc.

"Ledger devices produces vrs signatures with a canonical {v} value of 0 or 1."

This write-up goes through some brief details on vrs signatures, what makes signature validation fail on some web apps when signing using a Ledger device connected through MetaMask extension, followed by what to do about it.

- "I'm in hurry and just want things to work."
- "Reasonable. Scroll down some for the fix. It's just four rows of JS + comments."

At the far end of the signature there's {v}, which can hold multiple faces

The kind of signatures we're looking at are vrs signatures. In an execution context, we expect these signature to be a hexadecimal string that may or may not be prepended by 0x. A signature value of exactly 130 hexadecimal characters (0-9, a-f) can also be represented as 65 pairs of two's complemented hexadecimal values, or more commonly, 65 full bytes. A 65 byte signature, or vrs signature consists of three components: {r, s, v}.

  • A vrs signature is 65 bytes in total - {r} and {s} are each 32 bytes, followed by the 1 byte for the {v} value recovery id.
    • {r} and {s} are outputs of an ECDSA signature. Together they add up to the first 64 bytes.
    • {v} is the last byte in the signature, and nowadays for signature validadtion has to be either 27 (0x1b) or 28 (0x1c).

vrs-signature-3000x-2

The {v} identifier is important because since we are working with elliptic curves, multiple points on the curve can be calculated from r and s alone. This would result in two different public keys (thus addresses) that can be recovered. The {v} simply indicates which one of these points to use.

The alignment done towards the current standard {27, 28} {v} values for signatures in validation ensures that not several different signatures for the same message and address can be created. Thus, {v} values of {0, 1} should be denied within general signature validation.

What different {v} values in the signature means for validation

Signatures with v = {0, 1} and signatures with v = {27, 28} are both kinda valid - and some software will pass validation for the same address using both {v} values of 0 and 27, or similar {v} values of 1 and 28.

The current logic is that the low level crypto operations returns 0/1 (because that is the canonical {v} value), and the higher level signers (Frontier, Homestead, EIP155) convert that V to whatever Ethereum specs on top of secp256k1.

TL;DR: Use the high level signers, don't use the secp256k1 library directly. If you use the low level crypto library directly, you need to be aware of how generic ECC relates to Ethereum signatures.

Many tools and contracts will fail signature validation of signatures ending with 0x00 or 0x01.

Signing on a Ledger + relay to MetaMask extension

When signing a message on a Ledger and then relaying the signature to MetaMask, the {v} byte is still going to be 0 or 1 when it is sent to the dapp, instead of the expected 27 or 28. The invalid last byte will cause validation of the signature to fail as it is nowadays not an expected value.

Example – OpenZeppelin's ECDSA implementation

This is inline documentation from ECDSA.sol noting that OpenZeppelin's ECDSA implementation will not accept signatures where the {v} value isn't {27, 28}.

/*
 * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures:
 * this function rejects them by requiring the `s` value to be in the lower
 * half order, and the `v` value to be either 27 or 28.
 */
/* 
 * If your library generates signatures with 0/1 for v instead of 27/28, add 
 * 27 to v to accept these malleable signatures as well.
 */

Modification of last byte in vrs signatures if 0x00 or 0x01

Safety checks before modifying signature

For any modification of the signature's {v} value all of the following checks must pass.

  • In order to not accidentally cause surprise errors caused by library updates, we verify that the signature is a string.
  • The signature is allowed to be prepended with 0x or not. Besides any potential 0x prefix, the string must be 130 chars.
  • 130 hexadecimal characters can better be represented as 65 pairs of two's complemented values, or 65 full bytes.
  • By ensuring the signature is 130 hexadecimal chars (65 byte) we can be sure that it has each {r, s, v} component set.
  • Also - no changes of the {v} value of the signature will be altered unless the final two chars is either exactly 00 or 01.
typeof signature === "string" && /(^0[xX]|^)[0-9a-fA-F]{128}(00|01)$/.test(signature)

✨ A small fix that repairs signatures

  1. Add the following code block or something similar to where the signature value first is relayed into the web app.
  2. Test that it works and deploy the fix.
  3. Great! All Ledger users are now happy – and can use your app.
if (typeof signature === "string" && /(^0[xX]|^)[0-9a-fA-F]{128}(00|01)$/.test(signature)) { 
    // Ledger devices produces vrs signatures with a canonical v value of 0 or 1. When signing
    // a message on a Ledger and then relaying the signature to MetaMask, the v byte is still
    // going to be 0 or 1 when it is sent to the dapp, instead of the expected 27 or 28. The 
    // invalid last byte will cause validation of the signature to fail. This fixes the issue.

    // [details] https://github.com/ethereum/go-ethereum/issues/19751#issuecomment-504900739

    const sigV = (parseInt(signature.slice(-2), 16) + 27).toString(16);
    signature = signature.slice(0, -2) + sigV; 
}

Use a fix such as this if the web3 library you use for signing doesn't update the last byte on its own – for example if using web3.eth.personal.sign.

Example implementation: shows signature before + after patching the {v} value

Applying the if statement above within the callback argument for web3.eth.personal.sign, where we'll receive the signature from Ledger -> MetaMask. The last byte of the signature will be corrected, so that any upstream validation can correctly validate the signature / address.

Note how the last byte of the logged signature value is changed from 0x00 to 0x1b.

vsr-signature-term

The Ledger + MetaMask extension combination is most likely the most common setup for Ledger users on Ethereum.

Most other tools already fixes these kinds of issues in signatures. Many libs correct signatures (although some popular JS libs are still lacking) before exposing it to callback functions, and for example the cast CLI tool from Foundry outputs correct signatures. Also Ledger connections through Wallet Connect will relay valid signatures, which on some web apps can work as a temporary workaround if no {v} value patch is in place yet.


That's all – thanks for reading and thanks for patching your web apps

You can find me at:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment