solidity 合约签名验证

admin
admin 2021年09月15日
  • 在其它设备中阅读本文章

在智能合约中,能对签名进行验证来识别用户身份,达到离线授权许诺的目的,以减少线上交易次数和增强合约功能

合约哈希计算与签名验证

在签名过程中,并不是对原始内容签名,而是先计算为哈希,再对哈希签名。由于以太坊的改进协议 EIP-191 的影响,前端 js 库(web3 和 ethers)会在签名数据前加特殊前缀\x19Ethereum Signed Message:\n和长度来增强安全性,所以在智能合约中也需要加。在智能合约中拼接不定长数据和数字转字符串难以实现,可以将数据先行哈希一遍再拼接计算哈希,哈希长度是 32 字节,所以可以实现固定的组合前缀\x19Ethereum Signed Message:\n32, 再用 solidity 提供的函数 keccak256 计算哈希即可。

function _signHash(bytes memory _data) internal pure returns (bytes32) {
    return
        keccak256(
            abi.encodePacked(
                "\x19Ethereum Signed Message:\n32",
                keccak256(_data)
            )
        );
}

solidity 提供的函数 ecrecover 可以恢复签名地址,判断恢复的签名地址是否符合来验证身份。签名实际上是三个字段 R、S、V 的组合,和以太坊交易的签名中的一致,在前端 js 库中有把三个签名字段合并为一个字段(有工具函数可以分开)的情况,方便理解和签名传递。对于一个字段的签名,合约中可以使用汇编代码(节省 gas)来分解 R、S、V,再使用 ecrecover 函数恢复签名。

function _recoverSigner(bytes32 _hash, bytes memory _sig)
    internal
    pure
    returns (address)
{
    require(_sig.length == 65);
    (uint8 v, bytes32 r, bytes32 s) = (0, 0, 0);
    assembly {
        r := mload(add(_sig, 32))
        s := mload(add(_sig, 64))
        v := byte(0, mload(add(_sig, 96)))
    }

    return ecrecover(_hash, v, r, s);
}

ethers 签名生成

前端 js 库主要有 3 种方法生产

  1. 使用 ethers 库的 Wallet 钱包对象中的 signMessage 方法
  2. 使用 web3 库的 web3.eth.sign 方法
  3. 使用 web3 库的 web3.eth.accounts.sign 方法

另外 js 库也可以验证签名,web3.eth.accounts.recover使用原始信息和签名恢复签名地址,ethers.utils.recoverAddress则使用信息哈希和签名来恢复,ethers.utils.hashMessage可以直接计算带前缀的哈希

const Web3 = require("web3");
const ethers = require("ethers");
const web3 = new Web3();

(async () => {
    const privateKey = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318";
    const data = "hello world";

    console.log("web3带前缀hash:", web3.utils.sha3("\x19Ethereum Signed Message:\n" + data.length + data));
    console.log("ethers的hashMessage:", ethers.utils.hashMessage(data));

    const sig1 = web3.eth.accounts.sign(data, privateKey);
    console.log("web3.eth.accounts.sign签名:", sig1);

    const { address } = web3.eth.accounts.wallet.add(privateKey);
    const sig2 = await web3.eth.sign(data, address);
    console.log("web3.eth.sign签名:", sig2);

    const sig3 = await (new ethers.Wallet(privateKey)).signMessage(data);
    console.log("ethers.wallet.signMessage签名:", sig3);

    // ethers 实现手动签名
    const prefixedData = "\x19Ethereum Signed Message:\n" + data.length + data;
    const hash = ethers.utils.keccak256(ethers.utils.toUtf8Bytes(prefixedData));
    const sig4 = (new ethers.utils.SigningKey(privateKey)).signDigest(hash);
    console.log("ethers手动签名:", ethers.utils.joinSignature(sig4));

    // 签名地址恢复
    console.log(web3.eth.accounts.recover(data, sig3));
    console.log(ethers.utils.recoverAddress(hash, sig3));
})()

签名安全

  1. 签名需要增加防止重放攻击机制,防止同一个签名被重复使用,一个签名应该只能使用一次
  2. 可以采用类似以太坊 nonce 的机制来解决,只要合约中对签名进行验证就加一,这样一个签名只能使用一次
  3. 不同合约签名的 nonce 应该是不同的,即使是同一个合约也要考虑不同的方法使用不同的签名 noncce
  4. 签名是用户的私玥签发的,其他用户无法伪造,验证签名来判别身份是安全可行的
  5. 签发者无法约束或收回同一个 nonce 下不同的签名,以太坊的签名交易也是如此,可以采取提高 gas 费来优先打包交易