深入ethers库的底层实现,源码级别解读ERC191标准

  • Louis
  • 更新于 2024-07-21 09:35
  • 阅读 1266

ERC191是以太坊上的一个代币标准提案,全称是"EthereumRequestforComment191"。

基本定义:

ERC191是以太坊上的一个代币标准提案,全称是"Ethereum Request for Comment 191"。这个标准主要用于解决地址编码的问题,特别是在处理不同长度的地址时。

EIP-191 签名数据格式如下:

signed_data = 0x19 <version> <version specific data> <data to sign>

1. 前缀标识:

  • 0x19: 用于表示以太坊签名;
  • 0x45: 用于表示 Ethereum Signed Message (标准消息签名);

2. 消息数据:

  • 需要签名的数据。

3. 签名格式:

  • v, r, s 值,这些是标准的 ECDSA 签名值。

ERC191 签名的构造过程如下:

1. 拼接签名数据:

  • 0x19 前缀与实际消息数据拼接在一起。

2. 计算哈希值:

  • 对拼接后的数据进行 Keccak-256 哈希计算,得到消息的哈希值。

3. 签名消息:

  • 使用私钥对消息的哈希值进行签名,生成 v, r, s 值。

4. 验证签名:

  • 在智能合约中,通过 ecrecover 函数,根据 v, r, s 值和消息的哈希值,恢复出签名者的地址。

下面是一个使用 Solidity 编写的示例合约,用于验证 ERC191 签名:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ERC191Verifier {
    function getMessageHash(string memory _message) public pure returns (bytes32) {
        return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n", uint2str(bytes(_message).length), _message));
    }

    function verify(
        address _signer,
        string memory _message,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) public pure returns (bool) {
        bytes32 messageHash = getMessageHash(_message);
        return recoverSigner(messageHash, _v, _r, _s) == _signer;
    }

    function recoverSigner(
        bytes32 _messageHash,
        uint8 _v,
        bytes32 _r,
        bytes32 _s
    ) public pure returns (address) {
        return ecrecover(_messageHash, _v, _r, _s);
    }

    function uint2str(uint _i) internal pure returns (string memory _uintAsString) {
        if (_i == 0) {
            return "0";
        }
        uint j = _i;
        uint len;
        while (j != 0) {
            len++;
            j /= 10;
        }
        bytes memory bstr = new bytes(len);
        uint k = len;
        while (_i != 0) {
            k = k-1;
            uint8 temp = (48 + uint8(_i - _i / 10 * 10));
            bytes1 b1 = bytes1(temp);
            bstr[k] = b1;
            _i /= 10;
        }
        return string(bstr);
    }
}

在这个示例中:

  1. getMessageHash 函数生成带有 0x19 前缀的消息哈希。
  2. verify 函数用于验证签名是否正确。
  3. recoverSigner 函数通过 ecrecover 恢复出签名者的地址。
  4. uint2str 函数将 uint 类型转换为字符串,用于生成哈希。

通过这种方式,你可以在智能合约中验证 ERC191 标准的签名。

在实际应用中,签名是在客户端(例如使用 Web3.js 或 Ethers.js)生成的。下面我们演示整个步骤。

ethers库的实现:

可以使用typescript来实现同样的功能,需要借助ethers库:

import { ethers } from 'ethers';

async function manualERC191Sign() {
  console.log('ethers version:', ethers.version);
  console.log('-------------------------------------------------------------');

  // 1. 创建一个随机钱包(在实际应用中,你会使用真实的私钥)
  const wallet = ethers.Wallet.createRandom();
  // 2. 定义要签名的消息
  const message = 'helloWorld';

  // 3. 构造ERC191消息
  // 3.1 计算消息长度
  const messageLength = ethers.toUtf8Bytes(message).length;

  // 3.2 构造前缀
  const prefix = '\x19Ethereum Signed Message:\n' + messageLength;

  // 3.3 将前缀和消息拼接
  const prefixedMessage = prefix + message;

  console.log('前缀化消息:', prefixedMessage);

  // 4. 对拼接后的消息进行Keccak-256哈希
  const messageHash = ethers.keccak256(ethers.toUtf8Bytes(prefixedMessage));

  console.log('消息哈希:', messageHash);

  // 5. 使用私钥对哈希进行签名
  const signature = await wallet.signMessage(message);

  console.log('ERC191签名:', signature);

  // 6. 验证签名
  const recoveredAddress = ethers.verifyMessage(message, signature);

  console.log('签名地址:', wallet.address);
  console.log('恢复的地址:', recoveredAddress);
  console.log('签名验证:', recoveredAddress === wallet.address);

  // 7. 手动签名
  const msgHash = ethers.hashMessage(message);
  const signingKey = new ethers.SigningKey(wallet.privateKey);
  const signatureObject = signingKey.sign(msgHash);
  const manualSignature = ethers.Signature.from(signatureObject).serialized;

  console.log('手动生成的签名:', manualSignature);
  console.log('签名匹配:', manualSignature === signature);

  // 创建一个 Signature 对象
  const sig = ethers.Signature.from(signature);

  console.log('r:', sig.r);
  console.log('s:', sig.s);
  console.log('v:', sig.v);

  // 如果需要十六进制字符串形式,可以这样转换:
  console.log('r (hex):', ethers.hexlify(sig.r));
  console.log('s (hex):', ethers.hexlify(sig.s));
  console.log('v (hex):', `0x${sig.v.toString(16)}`); // 直接转换为十六进制字符串
}

export default manualERC191Sign;

计算消息长度为什么需要数据类型转换?

const messageLength = ethers.toUtf8Bytes(message).length;

字符串转换为UTF-8字节数组(通过 ethers.toUtf8Bytes())有几个重要原因:

  • 标准化编码: UTF-8 是一种广泛使用的字符编码标准,可以表示几乎所有的 Unicode 字符。通过将字符串转换为 UTF-8 字节,我们确保了字符串的一致性表示,无论原始字符串包含什么字符。

<!---->

  • 准确的字节长度: messageLength 变量存储的是消息的字节长度,而不是字符数。这在很多加密和区块链操作中非常重要,因为这些操作通常是基于字节而不是字符来进行的。

<!---->

  • 加密操作兼容性: 许多加密函数哈希函数需要字节数组作为输入,而不是普通的字符串。将消息转换为 UTF-8 字节使其可以直接用于这些操作。

<!---->

  • 跨平台一致性: 不同的系统可能使用不同的字符编码。通过显式转换为 UTF-8,我们确保了在不同平台上的一致性。

<!---->

  • 处理特殊字符: 某些字符(如表情符号或非 ASCII 字符)在 UTF-8 编码中可能占用多个字节。使用 toUtf8Bytes() 可以正确处理这些情况。

<!---->

  • 智能合约交互: 在以太坊智能合约中,字符串通常以字节数组的形式存储和处理。使用 UTF-8 字节可以确保与智能合约的兼容性。

一个简单的示例:

const message = "Hello, 世界!"; // 混合 ASCII 和 Unicode 字符
const messageBytes = ethers.toUtf8Bytes(message);
console.log(messageBytes.length); // 14 (而不是 10,因为 "世界" 各占3个字节)

在这个例子中,如果我们只计算字符数,结果会是10。但实际的 UTF-8 字节长度是 14,因为中文字符每个占用 3 个字节。

对拼接好的消息再次进行数据类型转换转换:

// 4. 对拼接后的消息进行Keccak-256哈希
const messageHash = ethers.keccak256(ethers.toUtf8Bytes(prefixedMessage));

我们发现在第4个步骤的时候,对于我们拼接好的数据:prefixedMessage,再次使用了ethers.toUtf8Bytes 进行了数据类型的转换,然后才调用 ethers.keccak256 进行hash运算。这个哈希函数需要字节数组作为输入。将 prefixedMessage 转换为 UTF-8 字节确保了整个消息(包括前缀)都被一致地编码,无论原始消息或前缀中包含什么字符。通过将整个 prefixedMessage 转换为字节,我们确保了在计算哈希时不会出现任何歧义或编码问题。

签名的原理实现:

这部分的具体实现,在手动签名的部分会分步骤演示:

const signature = await wallet.signMessage(message);

手动签名的四个步骤:

// 7. 手动签名
const msgHash = ethers.hashMessage(message);

const signingKey = new ethers.SigningKey(wallet.privateKey);

const signatureObject = signingKey.sign(msgHash);

const manualSignature = ethers.Signature.from(signatureObject).serialized;

第一步:创建 msgHash

const msgHash = ethers.hashMessage(message);

我们首先看hashMessage这个函数,我们打开源码看它的相关实现:

export function hashMessage(message: Uint8Array | string): string {
    if (typeof(message) === "string") { message = toUtf8Bytes(message); }
    return keccak256(concat([
        toUtf8Bytes(MessagePrefix),
        toUtf8Bytes(String(message.length)),
        message
    ]));
}

其中:MessagePrefix,在 ethers 库中的定义是这样的:

/**
 *  A constant for the [[link-eip-191]] personal message prefix.
 *
 *  (**i.e.** ``"\x19Ethereum Signed Message:\n"``)
 */
export const MessagePrefix: string = "\x19Ethereum Signed Message:\n";

concat函数是这样定义的:

/**
 *  Returns a [[DataHexString]] by concatenating all values
 *  within %%data%%.
 */
export function concat(datas: ReadonlyArray&lt;BytesLike>): string {
    return "0x" + datas.map((d) => hexlify(d).substring(2)).join("");
}

这个函数会自动添加以太坊特定的前缀("\x19Ethereum Signed Message:\n"),然后计算整个预处理后消息的 Keccak256 哈希。这是为了确保签名的安全性,防止签名被用于其他目的。

第二步:创建SigningKey对象

这一步创建了一个 SigningKey 对象,它封装了与私钥相关的签名功能。wallet.privateKey 是钱包的私钥。

我们看下SigningKey这个class的源码实现,初始化的时候,需要传入一个私钥,这样创建的对象就可以用用们传入的私钥进行签名操作了。

export class SigningKey {
  #privateKey: string;

  constructor(privateKey: BytesLike) {
    assertArgument(dataLength(privateKey) === 32, "invalid private key", "privateKey", "[REDACTED]");
    this.#privateKey = hexlify(privateKey);
  }

  get privateKey(): string { return this.#privateKey; }

  get publicKey(): string { return SigningKey.computePublicKey(this.#privateKey); }

  get compressedPublicKey(): string { return SigningKey.computePublicKey(this.#privateKey, true); }

  sign(digest: BytesLike): Signature {
    assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);

    const sig = secp256k1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), {
      lowS: true
    });

    return Signature.from({
      r: toBeHex(sig.r, 32),
      s: toBeHex(sig.s, 32),
      v: (sig.recovery ? 0x1c: 0x1b)
    });
  }

  computeSharedSecret(other: BytesLike): string {
    const pubKey = SigningKey.computePublicKey(other);
    return hexlify(secp256k1.getSharedSecret(getBytesCopy(this.#privateKey), getBytes(pubKey), false));
  }

  static computePublicKey(key: BytesLike, compressed?: boolean): string {
    let bytes = getBytes(key, "key");

    // private key
    if (bytes.length === 32) {
      const pubKey = secp256k1.getPublicKey(bytes, !!compressed);
      return hexlify(pubKey);
    }

    // raw public key; use uncompressed key with 0x04 prefix
    if (bytes.length === 64) {
      const pub = new Uint8Array(65);
      pub[0] = 0x04;
      pub.set(bytes, 1);
      bytes = pub;
    }

    const point = secp256k1.ProjectivePoint.fromHex(bytes);
    return hexlify(point.toRawBytes(compressed));
  }

  static recoverPublicKey(digest: BytesLike, signature: SignatureLike): string {
    assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);

    const sig = Signature.from(signature);

    let secpSig = secp256k1.Signature.fromCompact(getBytesCopy(concat([ sig.r, sig.s ])));
    secpSig = secpSig.addRecoveryBit(sig.yParity);

    const pubKey = secpSig.recoverPublicKey(getBytesCopy(digest));
    assertArgument(pubKey != null, "invalid signautre for digest", "signature", signature);

    return "0x" + pubKey.toHex(false);
  }

  static addPoints(p0: BytesLike, p1: BytesLike, compressed?: boolean): string {
    const pub0 = secp256k1.ProjectivePoint.fromHex(SigningKey.computePublicKey(p0).substring(2));
    const pub1 = secp256k1.ProjectivePoint.fromHex(SigningKey.computePublicKey(p1).substring(2));
    return "0x" + pub0.add(pub1).toHex(!!compressed)
  }
}

第三步:用私钥签署消息hash

使用 SigningKey 对象的 sign 方法来签署消息哈希。这个方法使用 ECDSA(椭圆曲线数字签名算法)来生成签名。返回的 signatureObject 包含签名的各个组成部分(r, s, v)。

源码实现上,其实是调用的这个sign方法:

sign(digest: BytesLike): Signature {
  assertArgument(dataLength(digest) === 32, "invalid digest length", "digest", digest);

  const sig = secp256k1.sign(getBytesCopy(digest), getBytesCopy(this.#privateKey), {
    lowS: true
  });

  return Signature.from({
    r: toBeHex(sig.r, 32),
    s: toBeHex(sig.s, 32),
    v: (sig.recovery ? 0x1c: 0x1b)
  });
}

第四步:序列化签名:

const manualSignature = ethers.Signature.from(signatureObject).serialized;

最后,我们使用 ethers.Signature.from 方法将签名对象转换为 Signature 实例,然后通过 .serialized 属性获取序列化的签名字符串。这个字符串是一个65字节的十六进制字符串,包含 r、s 和 v 值。

static from(sig?: SignatureLike): Signature {
            function assertError(check: unknown, message: string): asserts check {
  assertArgument(check, message, "signature", sig);
};

if (sig == null) {
  return new Signature(_guard, ZeroHash, ZeroHash, 27);
}

if (typeof(sig) === "string") {
  const bytes = getBytes(sig, "signature");
  if (bytes.length === 64) {
    const r = hexlify(bytes.slice(0, 32));
    const s = bytes.slice(32, 64);
    const v = (s[0] & 0x80) ? 28: 27;
    s[0] &= 0x7f;
    return new Signature(_guard, r, hexlify(s), v);
  }

  if (bytes.length === 65) {
    const r = hexlify(bytes.slice(0, 32));
    const s = bytes.slice(32, 64);
    assertError((s[0] & 0x80) === 0, "non-canonical s");
    const v = Signature.getNormalizedV(bytes[64]);
    return new Signature(_guard, r, hexlify(s), v);
  }

  assertError(false, "invalid raw signature length");
}

if (sig instanceof Signature) { return sig.clone(); }

// Get r
const _r = sig.r;
assertError(_r != null, "missing r");
const r = toUint256(_r);

// Get s; by any means necessary (we check consistency below)
const s = (function(s?: string, yParityAndS?: string) {
  if (s != null) { return toUint256(s); }

  if (yParityAndS != null) {
    assertError(isHexString(yParityAndS, 32), "invalid yParityAndS");
    const bytes = getBytes(yParityAndS);
    bytes[0] &= 0x7f;
    return hexlify(bytes);
  }

  assertError(false, "missing s");
})(sig.s, sig.yParityAndS);
assertError((getBytes(s)[0] & 0x80) == 0, "non-canonical s");

// Get v; by any means necessary (we check consistency below)
const { networkV, v } = (function(_v?: BigNumberish, yParityAndS?: string, yParity?: Numeric): { networkV?: bigint, v: 27 | 28 } {
  if (_v != null) {
    const v = getBigInt(_v);
    return {
      networkV: ((v >= BN_35) ? v: undefined),
      v: Signature.getNormalizedV(v)
    };
  }

  if (yParityAndS != null) {
    assertError(isHexString(yParityAndS, 32), "invalid yParityAndS");
    return { v: ((getBytes(yParityAndS)[0] & 0x80) ? 28: 27) };
  }

  if (yParity != null) {
    switch (getNumber(yParity, "sig.yParity")) {
      case 0: return { v: 27 };
      case 1: return { v: 28 };
    }
    assertError(false, "invalid yParity");
  }

  assertError(false, "missing v");
})(sig.v, sig.yParityAndS, sig.yParity);

const result = new Signature(_guard, r, s, v);
if (networkV) { result.#networkV =  networkV; }

// If multiple of v, yParity, yParityAndS we given, check they match
assertError(sig.yParity == null || getNumber(sig.yParity, "sig.yParity") === result.yParity, "yParity mismatch");
assertError(sig.yParityAndS == null || sig.yParityAndS === result.yParityAndS, "yParityAndS mismatch");

return result;
}

这个手动过程实际上模拟了 wallet.signMessage 方法内部的工作原理。主要区别在于 wallet.signMessage 是一个高级方法,它在内部处理了所有这些步骤,而手动方法让您可以更细粒度地控制签名过程。

总结:比较 wallet.signMessage 和手动方法:

wallet.signMessage(message) 是一个简单的高级方法,内部处理所有细节。手动方法让您可以分步执行过程,可能用于特殊情况或更深入的理解。在大多数情况下,使用 wallet.signMessage 就足够了,因为它更简单且不容易出错。但是,理解手动过程对于深入了解以太坊签名机制很有帮助,特别是在需要自定义签名过程或进行底层操作时。

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

0 条评论

请先 登录 后评论
Louis
Louis
web3 developer,技术交流或者有工作机会可加VX: magicalLouis