更安全的签名 - EIP712 结构化签名

EIP712又名结构化签名。让签名内容是结构化的、有字段名的,用户在钱包里可以清楚看到:我签的是啥:https://learnblockchain.cn/shawn_shaw

EIP712 是什么

EIP712 又名结构化签名。让签名内容是结构化的、有字段名的,用户在钱包里可以清楚看到:我签的是啥!

相信你在使用 metamask 的时候,看见过以下界面,这就是一个典型的 EIP712 签名。它详细展示出了你这次签名所签署的信息是什么。而不是傻乎乎的给你一段看不懂的字节。

image.png

EIP712的好处

想要说清楚 EIP712 是什么,我们必须先了解普通的签名方式。在上一篇文章中,我们聊到了普通数字签名。跳转链接

对于普通数字签名而言,只对原数据包的 bytes 数据进行签名,对于用户而言,根本无法辨别你所签名的内容是什么。所以,这个签名操作是危险的!!!

想象一下,假如有一个恶意网站,利用虚假信息骗你去领取空投,调用了一个 metamask 的签名,你根本看不到你要签名什么信息。你傻乎乎的就签名了,结果你链上的 ETH 和相关代币全被盗走了。损失大发。

image.png

EIP712 的出现,正是为了解决这一难题的。EIP712 结构化签名是一种更加高级、安全的签名方式。并且完全和普通签名兼容(使用相同的签名、验签方式,只是签名的信息格式固定了)

EIP712 可以提供给我们在进行 Dapp 交互的时候,展示出我们所需要签名的结构化信息,方便用户去辨认我们所签署的内容的影响范围。进而去避免盲签的风险。

EIP712 的结构

下面,我们从 EIP-712 开始,逐步去分析 EIP712 结构化签名的实现步骤和原理。

在结构方面,EIP712 签名分为三个部分,分别是 Domain(域)、Types(类型)、Message(信息)。我们以一个例子来进行讲解。

  1. Domain

    Domain 里面,主要负责定义签名的上下文环境。是固定的。比如,交互的合约地址是什么、链 id 是什么等固定的元信息。经过打包后 hash 存放起来(链上和链下均需存放)

  2. Types

    Types 里面,主要存放这次交互所要签名数据的结构,也是固定的。经过打包 hash 后存放起来(链上和链下均需存放)

假设我们要签名的结构体信息是

struct Permit {
    address owner;
    address spender;
    uint256 value;
}

需要经过 keccak() 计算后存放

keccak256("Permit(address owner,address spender,uint256 value)")
  1. Messages

消息,这里对应的是这次我们要签署的信息的具体值。这些值每次签名都是不固定的,但必须要和 Types 中的给定的类型要对得上(顺序、个数、类型都必须一致)。这个信息只需要链下存储,和签名的类型 hash 一起得出一个 structHash。链上只负责验证,不用存储但需要计算这个动态数据。

但实际上,对于链下和链上的交互而言,这个 Message 实际上是和 Types 合并在一起的。可以认为表现出来只有两个部分。Domain 数据 + Types 数据。可以理解为:DomainTypes 都是一个结构模板,而实际发送的数据是这个结构模板装载数据的内容。 例如,对于上面的数据,这里存放的是:

owner: 0x123...
spender: 0x456...
value: 100

EIP712 签名验签流程

  • 链下签名

    1. 构建 structHash 构造结构哈希,构造方式固定为keccak256(abi.encode(PERMIT_TYPE_HASH, ...)); 其中 PERMIT_TYPE_HASH 为 上面提到的 Types... 为我们实际传入的参数。

    2. 构建完整 messageHash 构建完整的 16 进制消息哈希需要用第一步的 structHash,构造的方式固定为keccak256(abi.encodePacked("\x19\x01",DOMAIN_SEPARATOR, structHash)); 其中 DOMAIN_SEPARATOR 为链上存储的 Domain + 数据(当合约部署好就固定了)

    3. ECDSA 签名 这里签名的过程和普通 ecdsa 签名兼容。

    4. 发送原始数据 + 签名 发送调用只需吧原始数据和签名发送到合约上即可。

  • 链上验签

    1. 使用原始数据恢复出来 structHash 和链下签名的流程类似,都是固定为keccak256(abi.encode(PERMIT_TYPE_HASH, ...)); 其中 PERMIT_TYPE_HASH 为 上面提到的 Types... 为我们实际传入的参数。

    2. 使用 structHash 恢复出来 messageHash 也和链下签名时的类似,构造的方式固定为keccak256(abi.encodePacked("\x19\x01",DOMAIN_SEPARATOR, structHash)); 其中 DOMAIN_SEPARATOR 为链上存储的 Domain + 数据(当合约部署好就固定了)

    3. 调用 ECDSA 验签 直接使用 ecdsa 的库,将恢复出来的消息 hash 和签名恢复出来地址。将这个地址和被授权的地址进行比对,若一致则说明验签成功。

image.png

链下签名链上验证实操

这里手搓模拟实现 EIP712 结构化签名合约。真实场景请用 Openzeppelin 代码。

  • 链上合约验签
contract ERC712Verify {
    // Domain 结构
    bytes32 private constant DOMAIN_TYPE_HASH =
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
    // Types 结构
    bytes32 private constant PERMIT_TYPE_HASH =
    keccak256("Permit(address owner,address spender,uint256 value)");

    // Domain 结构+值
    bytes32 public DOMAIN_SEPARATOR;

    constructor(string memory name, string memory version) {
        DOMAIN_SEPARATOR = _buildDomainSeparator(name, version);
    }

    // 实际调用业务
    // 根据传入数据,恢复出来消息 Hash ,验签
    function permitAndDoSomething(
        address owner,
        address spender,
        uint256 value,
        bytes memory signature
    ) external {
        // 恢复出来消息
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPE_HASH,
                owner,
                spender,
                value
            )
        );
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR, structHash));

        address recovered = ECDSA.recover(digest, signature);
        require(recovered == owner, "verify signature fail");

        //todo do some business logic

    }

    // 固定好 Domain 结构
    function _buildDomainSeparator(string memory name, string memory version) private view returns (bytes32) {
        return keccak256(abi.encode(DOMAIN_TYPE_HASH, keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this)));
    }
}
  • 链下签名
contract ERC712VarifyTest is Test {
    ERC712Verify public verifyContract;
    uint256 private privateKey;
    address private signer;
    bytes32 public DOMAIN_SEPARATOR;

    bytes32 private constant PERMIT_TYPE_HASH =
    keccak256("Permit(address owner,address spender,uint256 value)");

    // 初始化合约,测试账户和私钥
    function setUp() public {
        verifyContract = new ERC712Verify("MyApp", "1");
        privateKey = 0xA11CE; // 自定义私钥(测试用)
        DOMAIN_SEPARATOR = verifyContract.DOMAIN_SEPARATOR(); // 发起调用,获取固定的 Domain 结构 + 值
        signer = vm.addr(privateKey); // 用私钥获取 signer 地址

    }

    // 测试签名验证函数
    function testPermitAndDoSomething() public {
        address spender = address(0xBEEF);
        uint256 value = 100;

        // 构造结构体哈希
        bytes32 structHash = keccak256(
            abi.encode(
                PERMIT_TYPE_HASH,
                signer,
                spender,
                value
            )
        );

        // 构造完整的 digest
        bytes32 digest = keccak256(
            abi.encodePacked(
                "\x19\x01", // EIP-712 指定的固定前缀
                DOMAIN_SEPARATOR,
                structHash
            )
        );

        // 使用 signer 对 digest 进行签名
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
        bytes memory signature = abi.encodePacked(r, s, v);

        // 调用合约,验证签名并执行业务逻辑
        vm.expectCall(address(verifyContract), abi.encodeWithSelector(verifyContract.permitAndDoSomething.selector, signer, spender, value, signature));
        verifyContract.permitAndDoSomething(signer, spender, value, signature);

    }
}
  • foundry 测试
 forge test --match-path test/ERC712/ERC712Test.sol  -vvvv

image.png 测试通过,签名、验签成功。

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

0 条评论

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