EIP-712:类型化结构化数据的哈希与签名

1. 引言

以太坊 EIP-712: 类型化结构化数据的哈希与签名,是一种用于对类型化结构化数据(而不仅仅是字节串)进行哈希和签名 的标准。

其包括:

  • 编码函数正确性的理论框架,
  • 类似于 Solidity 结构体并兼容的结构化数据规范,
  • 对这些结构实例的安全哈希算法,
  • 将这些实例安全地包含在可签名消息集合中的方法,
  • 一种可扩展的域分离机制,
  • 新的 RPC 调用 eth_signTypedData
  • EVM 中该哈希算法的优化实现。

该标准不包含重放保护机制。

2. 动机

如果只关注字节串,数据签名已经是一个被解决的问题。不幸的是,在现实世界中,人们关心的是复杂且有意义的消息。对结构化数据进行哈希并非易事,错误可能会导致系统安全性的丧失。

因此,“don’t roll your own crypto 不要自己实现加密算法”这一原则适用。相反,需要使用经过同行评审和充分测试的标准方法。本 EIP 旨在成为这样的标准。

本 EIP 旨在改善链下消息签名在链上的可用性。链下消息签名的采用正在增加,因为它节省了 Gas 并减少了区块链上的交易数量。目前,已签名的消息是一个不透明的十六进制字符串,用户无法理解消息的组成内容。
在这里插入图片描述

在此,概述了一种方案,以编码数据及其结构,使其在签名时可供用户验证。下面是根据本提案,用户在签署消息时可能会看到的示例界面。
在这里插入图片描述

3. 规范

可签名消息集合从交易和字节串 𝕋 ∪ 𝔹⁸ⁿ 扩展到包括结构化数据 𝕊。新的可签名消息集合为 𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊。这些消息被编码为适合哈希和签名的字节串,如下所示:

  • encode(transaction : 𝕋) = RLP_encode(transaction)
  • encode(message : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message
    其中 len(message)message 字节数的 非零填充 ASCII 十进制编码。
  • encode(domainSeparator : 𝔹²⁵⁶, message : 𝕊) = "\x19\x01" ‖ domainSeparator ‖ hashStruct(message)
    其中 domainSeparatorhashStruct(message) 定义如下。

此编码是确定性的,因为其组成部分是确定性的。该编码是单射的,因为三种情况的首字节总是不同。(RLP_encode(transaction) 不会以 \x19 开头。)

此编码符合 EIP-191 规范。其中,“版本字节” 固定为 0x01,“版本特定数据” 是 32 字节的域分隔符 domainSeparator,“要签名的数据” 是 32 字节的 hashStruct(message)

3.1 类型化结构化数据 𝕊 的定义

为了定义所有结构化数据的集合,首先定义可接受的类型。类似于 ABIv2,这些类型与 Solidity 类型密切相关。采用 Solidity 语法有助于解释定义。该标准特定于以太坊虚拟机(EVM),但旨在不依赖于更高级别的语言。如:

struct Mail {
    address from;
    address to;
    string contents;
}

其中:

  • 定义:struct 结构体类型
    结构体类型的名称是一个有效的标识符,包含零个或多个成员变量。成员变量具有成员类型和名称。
  • 定义:member 成员类型
    成员类型可以是原子类型、动态类型或引用类型。
  • 定义:原子类型
    原子类型包括 bytes1bytes32uint8uint256int8int256booladdress。这些类型与 Solidity 定义相对应。需要注意的是,没有 uintint 的别名。此外,合约地址始终是 address。本标准不支持定点数类型,未来版本可能会增加新的原子类型。
  • 定义:动态类型
    动态类型包括 bytesstring。这些类型在类型声明方面类似于原子类型,但它们的编码方式不同。
  • 定义:引用类型
    引用类型包括数组和结构体。数组可以是固定大小的 Type[n] 或动态大小的 Type[]。结构体是对其他结构体的引用,通过其名称标识。本标准支持递归结构体类型。
  • 定义:结构化类型数据 𝕊
    𝕊 包含所有结构体类型的所有实例。

3.2 hashStruct 的定义

hashStruct 函数定义如下:

  • hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s))
    其中 typeHash = keccak256(encodeType(typeOf(s)))

注意:对于给定的结构体类型,typeHash 是一个常量,无需在运行时计算。

3.3 encodeType 的定义

结构体类型编码为 name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")",其中每个成员的表示形式为 type ‖ " " ‖ name

如,上述 Mail 结构体的编码为 Mail(address from,address to,string contents)

如果结构体类型引用了其他结构体类型(这些结构体类型又进一步引用其他结构体类型),则收集所有引用的结构体类型,按名称排序,并附加到编码中。如:

Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)

3.4 domainSeparator 的定义

domainSeparator = hashStruct(eip712Domain)

其中 eip712Domain 的类型是 EIP712Domain 结构体,包含以下字段之一或多个。这些字段用于区分不同的签名域。未使用的字段不会包含在结构体类型中。

  • string name 签名域的用户可读名称,如 DApp 或协议的名称。
  • string version 当前签名域的主要版本。不同版本的签名不兼容。
  • uint256 chainId EIP-155 的链 ID。
  • address verifyingContract 用于验证签名的合约地址。
  • bytes32 salt 作为协议的区分标识符。

未来的标准扩展可能会增加新的字段,用户代理可据此提供更多安全措施或提示用户。

3.5 eth_signTypedData JSON RPC 规范说明

eth_signTypedData 方法被添加到以太坊 JSON-RPC,类似于 eth_sign 方法。

3.5.1 eth_signTypedData

该签名方法计算以太坊特定的签名:
sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message))),如上所定义。

注意:用于签名的地址必须是解锁状态。

其中参数:

  • 1)Address - 20 字节 - 用于签名消息的账户地址。
  • 2)TypedData - 需要签名的结构化数据。

结构化数据是一个包含类型信息、域分隔符参数和消息对象的 JSON 对象。
以下是 TypedData 参数的 JSON Schema 定义:

{
  "type": "object",
  "properties": {
    "types": {
      "type": "object",
      "properties": {
        "EIP712Domain": {"type": "array"}
      },
      "additionalProperties": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "name": {"type": "string"},
            "type": {"type": "string"}
          },
          "required": ["name", "type"]
        }
      },
      "required": ["EIP712Domain"]
    },
    "primaryType": {"type": "string"},
    "domain": {"type": "object"},
    "message": {"type": "object"}
  },
  "required": ["types", "primaryType", "domain", "message"]
}

返回值为:

  • 返回 DATA 类型,即签名结果。
    eth_sign 方法相同,签名是一个以 0x 开头的 65 字节十六进制字符串,编码了以太坊黄皮书附录 F 中的 rsv 参数,采用大端字节序格式:
  • 字节 0-31:包含 r 参数
  • 字节 32-63:包含 s 参数
  • 最后一个字节:包含 v 参数

注意v 参数包括链 ID,符合 EIP-155 的规范。

示例:

  • 请求:

    curl -X POST --data '{"jsonrpc":"2.0","method":"eth_signTypedData","params":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", {"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}],"id":1}'
    
  • 返回结果:

    {
      "id": 1,
      "jsonrpc": "2.0",
      "result": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"
    }
    

一个示例展示如何使用 Solidity 的 ecrecover 来验证 eth_signTypedData 计算出的签名,代码可参考 Example.js
该合约已部署在 Ropsten 和 Rinkeby 测试网络上。

// using ethereumjs-util 7.1.3
const ethUtil = require('ethereumjs-util');

// using ethereumjs-abi 0.6.9
const abi = require('ethereumjs-abi');

// using chai 4.3.4
const chai = require('chai');

const typedData = {
    types: {
        EIP712Domain: [
            { name: 'name', type: 'string' },
            { name: 'version', type: 'string' },
            { name: 'chainId', type: 'uint256' },
            { name: 'verifyingContract', type: 'address' },
        ],
        Person: [
            { name: 'name', type: 'string' },
            { name: 'wallet', type: 'address' }
        ],
        Mail: [
            { name: 'from', type: 'Person' },
            { name: 'to', type: 'Person' },
            { name: 'contents', type: 'string' }
        ],
    },
    primaryType: 'Mail',
    domain: {
        name: 'Ether Mail',
        version: '1',
        chainId: 1,
        verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
    },
    message: {
        from: {
            name: 'Cow',
            wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
        },
        to: {
            name: 'Bob',
            wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
        },
        contents: 'Hello, Bob!',
    },
};

const types = typedData.types;

// Recursively finds all the dependencies of a type
function dependencies(primaryType, found = []) {
    if (found.includes(primaryType)) {
        return found;
    }
    if (types[primaryType] === undefined) {
        return found;
    }
    found.push(primaryType);
    for (let field of types[primaryType]) {
        for (let dep of dependencies(field.type, found)) {
            if (!found.includes(dep)) {
                found.push(dep);
            }
        }
    }
    return found;
}

function encodeType(primaryType) {
    // Get dependencies primary first, then alphabetical
    let deps = dependencies(primaryType);
    deps = deps.filter(t => t != primaryType);
    deps = [primaryType].concat(deps.sort());

    // Format as a string with fields
    let result = '';
    for (let type of deps) {
        result += `${type}(${types[type].map(({ name, type }) => `${type} ${name}`).join(',')})`;
    }
    return result;
}

function typeHash(primaryType) {
    return ethUtil.keccakFromString(encodeType(primaryType), 256);
}

function encodeData(primaryType, data) {
    let encTypes = [];
    let encValues = [];

    // Add typehash
    encTypes.push('bytes32');
    encValues.push(typeHash(primaryType));

    // Add field contents
    for (let field of types[primaryType]) {
        let value = data[field.name];
        if (field.type == 'string' || field.type == 'bytes') {
            encTypes.push('bytes32');
            value = ethUtil.keccakFromString(value, 256);
            encValues.push(value);
        } else if (types[field.type] !== undefined) {
            encTypes.push('bytes32');
            value = ethUtil.keccak256(encodeData(field.type, value));
            encValues.push(value);
        } else if (field.type.lastIndexOf(']') === field.type.length - 1) {
            throw 'TODO: Arrays currently unimplemented in encodeData';
        } else {
            encTypes.push(field.type);
            encValues.push(value);
        }
    }

    return abi.rawEncode(encTypes, encValues);
}

function structHash(primaryType, data) {
    return ethUtil.keccak256(encodeData(primaryType, data));
}

function signHash() {
    return ethUtil.keccak256(
        Buffer.concat([
            Buffer.from('1901', 'hex'),
            structHash('EIP712Domain', typedData.domain),
            structHash(typedData.primaryType, typedData.message),
        ]),
    );
}

const privateKey = ethUtil.keccakFromString('cow', 256);
const address = ethUtil.privateToAddress(privateKey);
const sig = ethUtil.ecsign(signHash(), privateKey);

const expect = chai.expect;
expect(encodeType('Mail')).to.equal('Mail(Person from,Person to,string contents)Person(string name,address wallet)');
expect(ethUtil.bufferToHex(typeHash('Mail'))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2',
);
expect(ethUtil.bufferToHex(encodeData(typedData.primaryType, typedData.message))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531bc129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8',
);
expect(ethUtil.bufferToHex(structHash(typedData.primaryType, typedData.message))).to.equal(
    '0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e',
);
expect(ethUtil.bufferToHex(structHash('EIP712Domain', typedData.domain))).to.equal(
    '0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f',
);
expect(ethUtil.bufferToHex(signHash())).to.equal('0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2');
expect(ethUtil.bufferToHex(address)).to.equal('0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826');
expect(sig.v).to.equal(28);
expect(ethUtil.bufferToHex(sig.r)).to.equal('0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d');
expect(ethUtil.bufferToHex(sig.s)).to.equal('0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562');

3.5.2 personal_signTypedData

还应有一个对应的 personal_signTypedData 方法,该方法接受账户的密码作为最后一个参数。

3.6 Web3 API 规范

在 Web3.js 版本 1 中新增了两个方法,与 web3.eth.signweb3.eth.personal.sign 方法对应。

3.6.1 web3.eth.signTypedData

web3.eth.signTypedData(typedData, address [, callback])

该方法使用特定账户签名结构化数据,该账户需要是解锁状态。

其中参数:

  • 1)Object - 包含域分隔符和待签名的结构化数据,结构遵循 eth_signTypedData JSON RPC 调用中指定的 JSON-Schema。
  • 2)String|Number - 用于签名的数据的地址,或者本地钱包 web3.eth.accounts.wallet 中的地址或索引。
  • 3)Function (可选) - 可选回调函数,第一个参数返回错误对象,第二个参数返回签名结果。

注意:参数 address 也可以是 web3.eth.accounts.wallet 中的地址或索引,此时会使用该账户的私钥本地签名。

返回值为:

  • Promise 返回 String 类型的签名,与 eth_signTypedData 方法返回的结果相同。

示例:
可以参考 eth_signTypedData JSON-RPC 示例中的 typedData 值:

web3.eth.signTypedData(typedData, "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826")
.then(console.log);
> "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c"

3.6.2 web3.eth.personal.signTypedData

web3.eth.personal.signTypedData(typedData, address, password [, callback])

此方法与 web3.eth.signTypedData 基本相同,但额外增加了 password 参数,类似于 web3.eth.personal.sign

4. 设计原理(Rationale)

encode 函数针对新的类型扩展了新的处理方式,编码的首字节用于区分不同的情况。
因此,直接使用 domainSeparatortypeHash 作为编码的起始位置是不安全的。尽管构造一个 typeHash 作为有效 RLP 编码交易的前缀很困难,但理论上仍然可能发生。

作用域分隔符(Domain Separator)的作用有:

  • 1)防止不同 DApp 之间的签名冲突
    假设两个 DApp 恰好设计了相同的结构,如 Transfer(address from,address to,uint256 amount),但它们的签名不应该兼容。引入作用域分隔符后,DApp 开发者可以确保不会出现签名冲突。

  • 2)允许同一 DApp 内部区分不同签名用途
    在同一个 DApp 内,同一结构可能需要多种签名。如,在 Transfer 交易中,可能既需要 from 签名,也需要 to 签名。通过提供不同的作用域分隔符,可以区分这两种签名。

方案 1:使用目标合约地址作为作用域分隔符

  • 这种方法可以解决合约间的类型冲突问题,但无法区分相同结构的不同签名用途。因此,该标准建议在适当情况下使用目标合约地址。

4.1 typeHash 设计原理

typeHash 设计为 Solidity 编译时的常量,如:

bytes32 constant MAIL_TYPEHASH = keccak256(
  "Mail(address from,address to,string contents)");

对于 typeHash,曾考虑过以下几种替代方案,但因各种原因被否决:

  • 方案 2:使用 ABIv2 函数签名
    采用 bytes4 作为哈希值的长度不足以抵抗哈希碰撞。此外,与函数签名不同,使用较长的哈希值几乎不会增加运行时成本。
  • 方案 3:将 ABIv2 函数签名扩展为 256 位
    这种方式虽然可以捕获类型信息,但无法表达函数以外的语义。例如,在 EIP-20EIP-721 中,transfer(address,uint256) 产生了实际碰撞:前者的 uint256 代表的是数量,而后者代表的是唯一 ID。总体而言,ABIv2 旨在增强兼容性,而哈希标准应优先考虑不可兼容性,以避免冲突。
  • 方案 4:将 256 位 ABIv2 签名扩展为包含参数名和结构体名
    如,Mail 结构体可以被编码为: Mail(Person(string name,address wallet) from,Person(string name,address wallet) to,string contents)
    但这种方案比现有的解决方案要长得多,并且字符串的长度可能会随着输入的增加呈指数级增长(如:struct A { B a; B b; }; struct B { C a; C b; }; …)。此外,该方案不支持递归结构体类型(如:struct List { uint256 value; List next; })。
  • 方案 5:包含 natspec 文档
    这种方式在 schemaHash 中加入了更多的语义信息,进一步降低了哈希碰撞的可能性。然而,这会导致文档的扩展和修改成为破坏性变更(breaking change),违背了通常的假设。此外,这也使 schemaHash 机制变得过于冗长。

4.2 encodeData 设计原理

encodeData 允许 Solidity 轻松实现 hashStruct 方法:

function hashStruct(Mail memory mail) pure returns (bytes32 hash) {
    return keccak256(abi.encode(
        MAIL_TYPEHASH,
        mail.from,
        mail.to,
        keccak256(mail.contents)
    ));
}

同时,它也可以在 EVM 内高效地进行原地计算:

function hashStruct(Mail memory mail) pure returns (bytes32 hash) {
    // 计算子哈希
    bytes32 typeHash = MAIL_TYPEHASH;
    bytes32 contentsHash = keccak256(mail.contents);

    assembly {
        // 备份内存
        let temp1 := mload(sub(mail, 32))
        let temp2 := mload(add(mail, 128))

        // 写入 typeHash 和 contentsHash
        mstore(sub(mail, 32), typeHash)
        mstore(add(mail, 64), contentsHash)

        // 计算哈希
        hash := keccak256(sub(mail, 32), 128)

        // 恢复内存
        mstore(sub(mail, 32), temp1)
        mstore(add(mail, 64), temp2)
    }
}

原地计算的实现对结构体在内存中的布局做出了较强但合理的假设。具体而言,它假设结构体不会被分配到地址 32 以下的位置,成员按顺序存储,所有值都填充至 32 字节的边界,并且动态类型和引用类型以 32 字节的指针形式存储。

被否决的替代方案有:

  • 方案 6:紧凑打包(Tight Packing)
    在 Solidity 中,使用 keccak256 处理多个参数时,默认会采用紧凑打包的方式。这种方式可以最小化需要哈希的字节数,但在 EVM 中需要复杂的打包指令,因此不支持原地计算。
  • 方案 7:ABIv2 编码
    随着 abi.encode 的引入,可以使用 abi.encode 作为 encodeData 函数。然而,ABIv2 标准本身未能满足确定性安全准则。相同数据可能存在多种有效的 ABIv2 编码。此外,ABIv2 也不支持原地计算。
  • 方案 8:在 hashStruct 中省略 typeHash
    可以选择不在 hashStruct 中包含 typeHash,而是将其与域分隔符(domain separator)合并。这种方式更高效,但会导致 Solidity keccak256 哈希函数的语义不具备单射性(injective)。
  • 方案 9:支持循环数据结构
    当前标准针对树状数据结构进行了优化,但未定义循环数据结构的处理方式。要支持循环数据结构,需要维护一个栈来记录当前路径,并在检测到循环时使用栈偏移量进行替换。然而,这种方式的规范和实现都异常复杂,并且会破坏可组合性(composability),因为成员值的哈希值会依赖于遍历路径。
    同样,直接实现该标准对于有向无环图(DAG)来说也不是最优的。递归遍历成员时,可能会多次访问相同的节点。可以使用记忆化(memoization)来优化这一过程。

4.3 domainSeparator 的设计原理

由于不同的域(domain)有不同的需求,因此采用了一种可扩展的方案:DApp 指定一个 EIP712Domain 结构体类型,并创建一个 eip712Domain 实例,将其传递给用户代理(user-agent)。用户代理可以根据其中的字段采取不同的验证措施。

5. 向后兼容性(Backwards Compatibility)

当前的 RPC 调用、web3 方法以及 SomeStruct.typeHash 参数尚未被定义。定义它们不应影响现有 DApp 的行为。

在 Solidity 中,表达式 keccak256(someInstance)(其中 someInstance 是结构体类型 SomeStruct 的一个实例)是有效的语法。当前,它计算的是该实例内存地址的 keccak256 哈希值。这种行为应被视为危险的,因为在某些情况下它可能表现正确,但在其他情况下可能会导致确定性失败或单射性问题。依赖当前行为的 DApp 应被视为存在严重风险。

6. 测试用例

示例合约可以在 Example.sol 中找到,JavaScript 的签名示例可以参考 Example.js

// 示例合约
pragma solidity ^0.4.24;

contract Example {
    
    struct EIP712Domain {
        string  name;
        string  version;
        uint256 chainId;
        address verifyingContract;
    }

    struct Person {
        string name;
        address wallet;
    }

    struct Mail {
        Person from;
        Person to;
        string contents;
    }

    bytes32 constant EIP712DOMAIN_TYPEHASH = keccak256(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
    );

    bytes32 constant PERSON_TYPEHASH = keccak256(
        "Person(string name,address wallet)"
    );

    bytes32 constant MAIL_TYPEHASH = keccak256(
        "Mail(Person from,Person to,string contents)Person(string name,address wallet)"
    );

    bytes32 DOMAIN_SEPARATOR;

    constructor () public {
        DOMAIN_SEPARATOR = hash(EIP712Domain({
            name: "Ether Mail",
            version: '1',
            chainId: 1,
            // verifyingContract: this
            verifyingContract: 0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC
        }));
    }

    function hash(EIP712Domain eip712Domain) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            EIP712DOMAIN_TYPEHASH,
            keccak256(bytes(eip712Domain.name)),
            keccak256(bytes(eip712Domain.version)),
            eip712Domain.chainId,
            eip712Domain.verifyingContract
        ));
    }

    function hash(Person person) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            PERSON_TYPEHASH,
            keccak256(bytes(person.name)),
            person.wallet
        ));
    }

    function hash(Mail mail) internal pure returns (bytes32) {
        return keccak256(abi.encode(
            MAIL_TYPEHASH,
            hash(mail.from),
            hash(mail.to),
            keccak256(bytes(mail.contents))
        ));
    }

    function verify(Mail mail, uint8 v, bytes32 r, bytes32 s) internal view returns (bool) {
        // Note: we need to use `encodePacked` here instead of `encode`.
        bytes32 digest = keccak256(abi.encodePacked(
            "\x19\x01",
            DOMAIN_SEPARATOR,
            hash(mail)
        ));
        return ecrecover(digest, v, r, s) == mail.from.wallet;
    }
    
    function test() public view returns (bool) {
        // Example signed message
        Mail memory mail = Mail({
            from: Person({
               name: "Cow",
               wallet: 0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826
            }),
            to: Person({
                name: "Bob",
                wallet: 0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB
            }),
            contents: "Hello, Bob!"
        });
        uint8 v = 28;
        bytes32 r = 0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d;
        bytes32 s = 0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562;
        
        assert(DOMAIN_SEPARATOR == 0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f);
        assert(hash(mail) == 0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e);
        assert(verify(mail, v, r, s));
        return true;
    }
}

相应的JavaScript签名示例代码为:

// using ethereumjs-util 7.1.3
const ethUtil = require('ethereumjs-util');

// using ethereumjs-abi 0.6.9
const abi = require('ethereumjs-abi');

// using chai 4.3.4
const chai = require('chai');

const typedData = {
    types: {
        EIP712Domain: [
            { name: 'name', type: 'string' },
            { name: 'version', type: 'string' },
            { name: 'chainId', type: 'uint256' },
            { name: 'verifyingContract', type: 'address' },
        ],
        Person: [
            { name: 'name', type: 'string' },
            { name: 'wallet', type: 'address' }
        ],
        Mail: [
            { name: 'from', type: 'Person' },
            { name: 'to', type: 'Person' },
            { name: 'contents', type: 'string' }
        ],
    },
    primaryType: 'Mail',
    domain: {
        name: 'Ether Mail',
        version: '1',
        chainId: 1,
        verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
    },
    message: {
        from: {
            name: 'Cow',
            wallet: '0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826',
        },
        to: {
            name: 'Bob',
            wallet: '0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB',
        },
        contents: 'Hello, Bob!',
    },
};

const types = typedData.types;

// Recursively finds all the dependencies of a type
function dependencies(primaryType, found = []) {
    if (found.includes(primaryType)) {
        return found;
    }
    if (types[primaryType] === undefined) {
        return found;
    }
    found.push(primaryType);
    for (let field of types[primaryType]) {
        for (let dep of dependencies(field.type, found)) {
            if (!found.includes(dep)) {
                found.push(dep);
            }
        }
    }
    return found;
}

function encodeType(primaryType) {
    // Get dependencies primary first, then alphabetical
    let deps = dependencies(primaryType);
    deps = deps.filter(t => t != primaryType);
    deps = [primaryType].concat(deps.sort());

    // Format as a string with fields
    let result = '';
    for (let type of deps) {
        result += `${type}(${types[type].map(({ name, type }) => `${type} ${name}`).join(',')})`;
    }
    return result;
}

function typeHash(primaryType) {
    return ethUtil.keccakFromString(encodeType(primaryType), 256);
}

function encodeData(primaryType, data) {
    let encTypes = [];
    let encValues = [];

    // Add typehash
    encTypes.push('bytes32');
    encValues.push(typeHash(primaryType));

    // Add field contents
    for (let field of types[primaryType]) {
        let value = data[field.name];
        if (field.type == 'string' || field.type == 'bytes') {
            encTypes.push('bytes32');
            value = ethUtil.keccakFromString(value, 256);
            encValues.push(value);
        } else if (types[field.type] !== undefined) {
            encTypes.push('bytes32');
            value = ethUtil.keccak256(encodeData(field.type, value));
            encValues.push(value);
        } else if (field.type.lastIndexOf(']') === field.type.length - 1) {
            throw 'TODO: Arrays currently unimplemented in encodeData';
        } else {
            encTypes.push(field.type);
            encValues.push(value);
        }
    }

    return abi.rawEncode(encTypes, encValues);
}

function structHash(primaryType, data) {
    return ethUtil.keccak256(encodeData(primaryType, data));
}

function signHash() {
    return ethUtil.keccak256(
        Buffer.concat([
            Buffer.from('1901', 'hex'),
            structHash('EIP712Domain', typedData.domain),
            structHash(typedData.primaryType, typedData.message),
        ]),
    );
}

const privateKey = ethUtil.keccakFromString('cow', 256);
const address = ethUtil.privateToAddress(privateKey);
const sig = ethUtil.ecsign(signHash(), privateKey);

const expect = chai.expect;
expect(encodeType('Mail')).to.equal('Mail(Person from,Person to,string contents)Person(string name,address wallet)');
expect(ethUtil.bufferToHex(typeHash('Mail'))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2',
);
expect(ethUtil.bufferToHex(encodeData(typedData.primaryType, typedData.message))).to.equal(
    '0xa0cedeb2dc280ba39b857546d74f5549c3a1d7bdc2dd96bf881f76108e23dac2fc71e5fa27ff56c350aa531bc129ebdf613b772b6604664f5d8dbe21b85eb0c8cd54f074a4af31b4411ff6a60c9719dbd559c221c8ac3492d9d872b041d703d1b5aadf3154a261abdd9086fc627b61efca26ae5702701d05cd2305f7c52a2fc8',
);
expect(ethUtil.bufferToHex(structHash(typedData.primaryType, typedData.message))).to.equal(
    '0xc52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e',
);
expect(ethUtil.bufferToHex(structHash('EIP712Domain', typedData.domain))).to.equal(
    '0xf2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f',
);
expect(ethUtil.bufferToHex(signHash())).to.equal('0xbe609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2');
expect(ethUtil.bufferToHex(address)).to.equal('0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826');
expect(sig.v).to.equal(28);
expect(ethUtil.bufferToHex(sig.r)).to.equal('0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d');
expect(ethUtil.bufferToHex(sig.s)).to.equal('0x07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b91562');

7. 安全性考量(Security Considerations)

7.1 重放攻击(Replay Attacks)

本标准仅涉及消息签名和签名验证。在许多实际应用中,签名消息用于授权某项操作,如代币交换。实施者必须确保应用程序在收到相同的签名消息两次时能够正确处理。如,重复的消息应被拒绝,或者授权的操作应具有幂等性。具体实现方式取决于应用场景,超出了本标准的范围。

7.2 交易抢跑攻击(Frontrunning Attacks)

可靠地广播签名的机制取决于具体的应用,超出了本标准的范围。当签名被广播到区块链并用于合约时,应用程序必须能够防范抢跑攻击。在这种攻击中,攻击者拦截签名并在原始预期用途发生之前将其提交到合约。应用程序应确保在攻击者率先提交签名时仍能正确处理,如拒绝该签名,或仅产生与签名者预期完全相同的效果。

参考资料

[1] EIP-712: Typed structured data hashing and signing – A procedure for hashing and signing of typed structured data as opposed to just bytestrings.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值