解决Golang和Web3签名结果不同

因为在做区块链工作时,出现了前端用户签名后的数据后端无法进行校验,或者后端签名数据链上合约无法校验的问题。通过查找资料,了解到了具体原因:

以太坊有两种消息,交易𝕋和字节串𝔹⁸ⁿ。这些分别用eth_sendTransactioneth_sign来签名。最初的编码函数encode : 𝕋∪𝔹⁸ⁿ→𝔹⁸ⁿ如下定义:

  • encode(t : T) = RLP_encode(t)
  • encode(b : 𝔹⁸ⁿ) = b

独立来看的话,它们都满足要求的属性,但是合在一起看就不满足了。如果我们采用b = RLP_encode(t)就会产生碰撞(即两种编码得到的结果就是一样的了)。在Geth PR 2940中,通过修改编码函数的第二条定义,这种情况得到了缓解:

PR 2940

即在第二种编码方式前加上”\x19Ethereum Signed Message:\n” + len(message)

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"crypto/ecdsa"
"fmt"
"log"

"github.com/ethereum/go-ethereum/common/hexutil"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
)

func hash(data []byte) common.Hash {
msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data)
return crypto.Keccak256Hash([]byte(msg))
}

func signMsg(msg, privKeyHex string) ([]byte, ecdsa.PublicKey) {
privKeyECDSA, err := crypto.HexToECDSA(privKeyHex)
if err != nil {
log.Fatal(err)
}
a, _ := hexutil.Decode(msg)
msgHash := hash(a)
signature, err := crypto.Sign(msgHash.Bytes(), privKeyECDSA)
if err != nil {
log.Fatal(err)
}
return signature, privKeyECDSA.PublicKey
}

var fakeP = "f9a87df69a477fe6b7862f496637c75c2ae5efdff409f3a4234d506d96e8bdda"

func main() {
sig, _ := signMsg("0x76641f63d887703b83099f9d138f547e2d5b5ef71ba81ef45s2bf38814c5600e", fakeP)
fmt.Println("signature", hexutil.Encode(sig))
}

  • encode(b : 𝔹⁸ⁿ) = "\x19Ethereum Signed Message:\n" ‖ len(b) ‖ b 其中len(b)b中字节数的ASCII十进制编码。

这就解决了两个定义之间的冲突,因为RLP_encode(t : 𝕋)永远不会以\x19作为开头。但新的编码函数依然存在确定性和单射性风险,仔细思考这些对我们很有帮助。

原来,上面的定义并不具有确定性。对一个4个字节大小的字符串b来说,用len(b) = "4"或者len(b) = "004"都是有效的。我们可以进一步要求所有表示长度的十进制编码前面不能有0并且len("")="0"来解决这个问题。

上面的定义并不是明显无碰撞阻力的。一个以"\x19Ethereum Signed Message:\n42a…"开头的字节串到底表示一个42字节大小的字符串,还是一个以"2a"作为开头的字符串?这个问题在 Geth issue #14794中被提出来,也直接促使了trezor不使用这个标准。幸运的是这并没有导致真正的碰撞因为编码后的字节串总长度提供了足够的信息来消除这个歧义。

如果忽略了len(b),确定性和单射性就没有那么重要了。重点是,很难将任意集合映射到字节串,而不会在编码函数中引入安全问题。目前对eth_sign的设计仍然将字节串作为输入,并期望实现者提供一种编码。