Skip to content

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

Description

@MagicalBridge

基本定义:

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<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 值。

staticfrom(sig?: SignatureLike): Signature{functionassertError(check: unknown,message: string): asserts check{assertArgument(check,message,"signature",sig);};if(sig==null){returnnewSignature(_guard,ZeroHash,ZeroHash,27);}if(typeof(sig)==="string"){constbytes=getBytes(sig,"signature");if(bytes.length===64){constr=hexlify(bytes.slice(0,32));consts=bytes.slice(32,64);constv=(s[0]&0x80) ? 28: 27;s[0]&=0x7f;returnnewSignature(_guard,r,hexlify(s),v);}if(bytes.length===65){constr=hexlify(bytes.slice(0,32));consts=bytes.slice(32,64);assertError((s[0]&0x80)===0,"non-canonical s");constv=Signature.getNormalizedV(bytes[64]);returnnewSignature(_guard,r,hexlify(s),v);}assertError(false,"invalid raw signature length");}if(siginstanceofSignature){returnsig.clone();}// Get rconst_r=sig.r;assertError(_r!=null,"missing r");constr=toUint256(_r);// Get s; by any means necessary (we check consistency below)consts=(function(s?: string,yParityAndS?: string){if(s!=null){returntoUint256(s);}if(yParityAndS!=null){assertError(isHexString(yParityAndS,32),"invalid yParityAndS");constbytes=getBytes(yParityAndS);bytes[0]&=0x7f;returnhexlify(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){constv=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")){case0: return{v: 27};case1: return{v: 28};}assertError(false,"invalid yParity");}assertError(false,"missing v");})(sig.v,sig.yParityAndS,sig.yParity);constresult=newSignature(_guard,r,s,v);if(networkV){result.#networkV =networkV;}// If multiple of v, yParity, yParityAndS we given, check they matchassertError(sig.yParity==null||getNumber(sig.yParity,"sig.yParity")===result.yParity,"yParity mismatch");assertError(sig.yParityAndS==null||sig.yParityAndS===result.yParityAndS,"yParityAndS mismatch");returnresult;}

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions