签名的工作原理

eth-signature02

签名流程

  1. 生成消息哈希:对消息进行哈希,得到 messageHash, 例如: messageHash = keccak256( "\x19Ethereum Signed Message:\n" + message.length + message )
  2. 使用私钥签名:签名者使用其私钥对 messageHash 进行签名,得到 (r, s, v)
  3. 消息验证: 验签者通过 (r, s, v)messageHash,恢复出签名者的公钥地址, 验证恢复的公钥是否与预期地址一致

椭圆曲线签名(ECDSA)

与比特币相同, 以太坊使用 secp256k1 椭圆曲线作为加密算法, ECDSA 签名则可以用来验证消息的真实性和完整性,同时证明消息是由特定私钥签名的

  • 私钥 (Private Key):随机生成的 256 位(32 字节)数值, 必须严格保密,用于生成签名。
  • 公钥 (Public Key):从私钥通过椭圆曲线生成的公开信息, 用于验证签名。
  • 以太坊地址 (Ethereum Address):公钥的 Keccak256 哈希值的最后 20 字节, 地址用于识别账户

签名的组成: 以太坊的签名由以下 3 个部分组成:

  • r:签名的第一部分(ECDSA 的 x 坐标值)。
  • s:签名的第二部分(ECDSA 的数字)。
  • v:恢复参数,标识签名的校验点(0x1b 或 0x1c,即 27 或 28)

EIP-191EIP-712 签名标准

EIP-191 简单消息签名

EIP-191在以太坊签名中加入了一个标准化的前缀,确保消息签名与交易签名之间不会发生混淆, 阻止链下消息的签名被恶意用于链上交易

签名结构

基本结构类似: 0x19 | <1 byte version> | <version specific data> <data to sign> 这种: eth-signature01

  • 0x19 初始字节: 这个初始字节确保 signed_data 不是有效的 RLP 编码,从而防止签名数据被误解为以太坊交易
  • <1 byte version>:可以自行定义签名数据版本,占用一字节,分为:
    • 0x00:适用于通用消息签名场景
    • 0x45:为消息绑定合约地址,增强交互安全性
  • <version sepific data>: 不定长的消息头数据;
  • <data to sign>:原始签名数据

Version 0x00 和 0x45 的对比

特性 Version(0x00) Version(0x45)
用途 通用的链下消息签名 绑定合约地址的消息签名
签名结构 keccak256("\x19Ethereum Signed Message:\n" + message) keccak256("\x19\x45" + contract_address + message)
签名场景 适合所有通用签名场景(如链下签名授权) 适用于需要绑定合约地址的签名场景(如特定合约授权)
签名与地址绑定 不绑定合约地址 必须绑定一个合约地址
签名安全性 较高 更高,防止跨合约重用签名

EIP-712 结构化数据签名

EIP-712 通过对消息进行结构化编码和类型化哈希,确保签名过程清晰可读,避免传统链下签名中的数据歧义问题

签名结构

结构为: keccak256("\x19\x01" + domainSeparator + messageHash)

其中 domainSeparator 也是一个哈希,用于标识签名所依赖的上下文(如应用名称、版本、链 ID 等),其结构如下:

struct EIP712Domain {
    string name;              // DApp 名称
    string version;           // DApp 版本
    uint256 chainId;          // 所属链 ID
    address verifyingContract; // 验证合约地址
}

签名流程如下:

  1. 定义结构化数据类型:定义类型描述(Type Definitions)和消息(Message Data)
{
  "types": {
    "Person": [
      { "name": "name", "type": "string" },
      { "name": "wallet", "type": "address" }
    ]
  },
  "primaryType": "Person",
  "domain": {
    "name": "DApp",
    "version": "1.0",
    "chainId": 1,
    "verifyingContract": "0x1c5630"
  },
  "message": {
    "name": "Alice",
    "wallet": "0xABCDEF"
  }
}
  1. 计算 domainSeparator
domainSeparator = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
    keccak256(bytes("DApp")),
    keccak256(bytes("1.0")),
    1,
    "0x1c5630"
));
  1. 计算 messageHash
messageHash = keccak256(abi.encode(
    keccak256("Person(string name,address wallet)"),
    keccak256(bytes("Alice")),
    "0xABCDEF"
));
  1. 计算最终签名哈希:
SIGNATURE_HASH = keccak256("\x19\x01" + domainSeparator + messageHash);

ERC20 中的离线签名应用

常见的 ERC20 支付方式有:

  • Transfer
  • Transfer +CallBack
  • Approve + TransferFrom

而利用离线签名就可以做到在链下生成签名来完成授权代币转账操作 Signature + TransferFrom

contract MyToken is ERC20("My Token", "MYT") {
    mapping(address => uint256) public nonces;
    constructor() {}

    function transferWithSignature(
        address from, address to, uint256 amount, 
        uint256 nonce, uint256 deadline,
        uint8 v, bytes32 r, bytes32 s
    ) public {
        require(block.timestamp <= deadline, "expired");
        require(nonces[from] == nonce, "invalid nonce");
        nonces[from]++;
        
        bytes32 hash = keccak256(abi.encodePacked(from, to, amount, nonce, deadline));
        address signer = ecrecover(hash, v, r, s);
        require(signer = from, "Invalid signature");
        _transfer(from, to, amount);
    } 
}

以太坊也提供了标准实现: EIP-2612, 参考 openzeppelin 库实现

abstract contract ERC20Permit is ERC20, IERC20Permit, EIP712, Nonces {
    bytes32 private constant PERMIT_TYPEHASH =
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

    /**
     * @dev Permit deadline has expired.
     */
    error ERC2612ExpiredSignature(uint256 deadline);

    /**
     * @dev Mismatched signature.
     */
    error ERC2612InvalidSigner(address signer, address owner);

    /**
     * @dev Initializes the {EIP712} domain separator using the `name` parameter, and setting `version` to `"1"`.
     *
     * It's a good idea to use the same `name` that is defined as the ERC20 token name.
     */
    constructor(string memory name) EIP712(name, "1") {}

    /**
     * @inheritdoc IERC20Permit
     */
    function permit(
        address owner,
        address spender,
        uint256 value,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) public virtual {
        if (block.timestamp > deadline) {
            revert ERC2612ExpiredSignature(deadline);
        }

        bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, _useNonce(owner), deadline));

        bytes32 hash = _hashTypedDataV4(structHash);

        address signer = ECDSA.recover(hash, v, r, s);
        if (signer != owner) {
            revert ERC2612InvalidSigner(signer, owner);
        }

        _approve(owner, spender, value);
    }

    /**
     * @inheritdoc IERC20Permit
     */
    function nonces(address owner) public view virtual override(IERC20Permit, Nonces) returns (uint256) {
        return super.nonces(owner);
    }

    /**
     * @inheritdoc IERC20Permit
     */
    // solhint-disable-next-line func-name-mixedcase
    function DOMAIN_SEPARATOR() external view virtual returns (bytes32) {
        return _domainSeparatorV4();
    }
}