智能合约的身份保证 - 数字签名

数字签名是什么数字签名,简单讲,就是一种证明「这份数据是我发的」的方法。本质上,就是用私钥去对一段消息去签名,对方用公钥去验证这份签名,证明这份私钥是由我发送的并且消息没有遭到篡改:https://learnblockchain.cn/shawn_shaw

数字签名是什么

数字签名,简单讲,就是一种 证明「这份数据是我发的」 的方法。本质上,就是用私钥去对一段消息去签名,对方用公钥去验证这份签名,证明这份私钥是由我发送的并且消息没有遭到篡改。 在以太坊上,使用到的数字签名(加密)算法是 ECDSA

数字签名的特性

  • 身份验证 数字签名能证明:消息确实是由某个持有私钥的人发出的。因为只有私钥持有者才能生成正确的签名,别人伪造不了。

  • 消息完整 签名是基于消息的哈希生成的。如果消息内容哪怕只改动一丁点(哪怕一个标点符号),hash 就变了,签名也验证不了。

  • 不可抵赖 一旦签了名,就无法否认自己签过。因为只有你拥有私钥,签名是你自己产生的,别人通过签名和消息可以恢复出来你的身份,别人无法伪造。

    几个概念

    数据包

    指得是原始的数据消息。例如一段字符串数据

    Hello World

    消息

    指的是原始数据经过 hash 算法生成的 32 byte 的数据。在以太坊中,这个消息会经过两次 keccak() 的算法进行生成。

    1. 第一次:直接 hash
      // 1. 直接把字符串hash
      bytes32 messageHash = keccak256(bytes(message));
    2. 第二次:加上以太坊的标志二次 hash 加上这个字符串标志的作用是防止重放攻击。细节我们在下面转化消息的步骤详解。
      return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));

      私钥

      ECDSA 私钥本身,是一个 32 字节的大整数。生成方式如下(go-ethereum库)

      privateKey, err := crypto.GenerateKey()

      生成结果为:

      0x9db7a2287bf11462793f2b5c726d12ce79f2e25fbc9d08316b55386061e35a4c

      公钥

      公钥是由私钥进行生成的。有两种公钥,压缩公钥和未压缩公钥。以太坊中使用的公钥为未压缩公钥。

  • 未压缩公钥: 结构为:0x04 + X坐标+ Y 坐标。 大小为:uint8 + bytes32 + bytes32 = 65 字节 如:

0x049a9c81e4f44f721e610a9c3cb3c2f6a8ca57b142309ba5c51d6b135988594d8fe3e5c0c5649f413f62de7c3c3e3b4974600bc517a637da51967b15e79b3d9252
  • 压缩公钥:压缩公钥是未压缩公钥的编码后的公钥 结构为:0x020x03 + X 坐标 如:
0x021e2dda68120487e0807300c0f6e8e7d9e974a40f59ae3060d2ae6077fa66c092

地址

在以太坊中,地址格式为未压缩公钥去掉 0x04后,经过 keccak() 哈希取后 20 字节(4016 进制字符)而产生的。 如

0x49755928d2471581649f356f2a414e36d334055d

签名

签名本质上也是一个 65 字节的字节序列,分别是有三个值(r、s、v),大小分别为 32 字节、32 字节、1 字节。r、s、v 简单拼接起来就是一个完整的签名消息。 如:

0x
2c6404e1145f38a8eb7b6a5d7648e6a711f3dfd32e7d7fa8a6c4b17e6b6c6b6d  // r (32字节)
56c4f0a1b0494c6578723e1c5e8f302ff7f6ba674fbec6d1571318c43259b2e4  // s (32字节)
1b                                                               // v (1字节)

在以太坊中,可以通过 rsv 值以及以太坊签名消息来进行恢复出来公钥(可推导出地址)来进行验证。 如:

        // 恢复 signer
        address recovered = ECDSA.recover(ethSignedHash, v, r, s);

签名算法

以太坊中使用到的签名算法是 ECDSA(一种基于椭圆曲线的签名算法),使用到的椭圆曲线是 secp256k1。 签名流程为:

  1. 先对消息做 Hashkeccak256 处理原始消息,得到 messageHash。 (如果是普通签名,还会加一段 \x19Ethereum Signed Message:\n32 前缀)
  2. 用私钥对 messageHash 签名 使用 secp256k1 曲线和 ECDSA 签名算法,生成 (r, s, v)
  3. 得到最终签名 把 r || s || v按顺序拼接成 65 字节数据。

    以太坊签名

    签名流程

  4. 数据打包(一次 hash
        // 1. 直接把字符串hash
        bytes32 messageHash = keccak256(bytes(message));

    第一次 hash 是直接对原始交易的数据进行使用 keccak() 函数进行 hash 化。

  5. 转化消息(二次 hash
    return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));

    在这一步中,对第一步获得的 hash 值添加以太坊的字符串标识再进行一次 hash。通过添加以太坊的标识,明确告诉链上验证者「这是人为签名的消息,不是链上的交易或指令」。如果不加前缀,攻击者可以拿着你签名过的数据,在链上伪造一些危险的行为!这就是签名可重放攻击,例如:

    from: 0xaaa...aaa
    to:   0xbbb...bbb
    value: 1 ETH
    nonce: 5
    gasLimit: 21000
    gasPrice: 20 gwei
    chainId: 1

    假设我们有这一份链上交易数据。正常来说,直接使用 keccak256(hash) 后进行签名是可以发上以太坊网络进行交易发起的。

那么,假如此时有攻击者让你签了这个数据,拿到这个交易后直接发送到区块链网络,即发起了一次签名重放攻击。你的资金就发生了损失。(因为 signature 可以证明原交易数据的完整性)

但是,如果进行二次哈希,在第二次计算 hash 的时候,添加上以太坊的字符串。那么,这个签名则会被认为是离线签名,无法进行执行交易等危险行为。(因为最终 signature 只能保证 \x19Ethereum Signed Message:\n32 + hash 的完整性, 而不是可执行交易数据的完整性)

总得来说,二次签名并加上以太坊字符串标识是为了区分 链上交易 和 链下签名数据 两种类型。防签名重放攻击则是为了避免签名被使用在链上交易上面,避免资金丢失。

  1. 签名 签名这一步我们一般都是在链下进行,因为智能合约不应保存私钥信息。但这里使用到 foundryvm 来进行模拟签名。 通过使用私钥对消息 hash 进行签名,可以获取签名信息,也就是 r、s、v 三个值。
        // 3. 签名,这一步和之前的步骤都发生在链下
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, ethSignedHash);

    验签流程

  2. 恢复公钥(地址) 在链上恢复公钥(可推导出地址)相对比较简单。这里使用到 openzepplinECDSA 库,参数为: 消息哈希、r、s、v
        // 4. 恢复 signer
        address recovered = ECDSA.recover(ethSignedHash, v, r, s);
  1. 对比公钥(地址) 经过上一步恢复出来 recovered (一个地址),我们就可以进行签名人匹配,如果相等,则说明消息完整且发起者为 signer
        assertEq(recovered, signer, "Recovered signer does not match");

代码实操

contract TestSignVerifyOZ is Test {
    using ECDSA for bytes32;

    address signer;
    uint256 privateKey;

    function setUp() public {
        privateKey = 123456789;
        signer = vm.addr(privateKey);
    }

    function testSignAndRecoverOZ() public {
        string memory message = "hello world";

        // 1. 直接把字符串hash
        bytes32 messageHash = keccak256(bytes(message));

        // 2. 用 OZ 帮我们加前缀(标准 Ethereum Signed Message 格式)
        bytes32 ethSignedHash = MessageHashUtils.toEthSignedMessageHash(messageHash);
        console.log("message is:");
        console.logBytes32( ethSignedHash  );

        // 3. 签名,这一步和之前的步骤都发生在链下
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, ethSignedHash);
        console.log("signature is:");
        console.logBytes( abi.encodePacked(r,s,v)  );

        // 4. 恢复 signer
        address recovered = ECDSA.recover(ethSignedHash, v, r, s);

        console.log("recovered is:");
        console.logAddress( recovered  );
        console.log("signer is:");
        console.log(signer);
        assertEq(recovered, signer, "Recovered signer does not match");
    }
}

image.png

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
shawn_shaw
shawn_shaw
web3潜水员、技术爱好者、web3钱包开发工程师、欢迎交流工作机会。欢迎骚扰:vx:cola_ocean