之前在 Solidity 智能合約 - ecrecover 簽章檢驗這篇文章,說明了智能合約如何檢驗簽章,這篇文章將說明如何使用 Web3.js 來進行簽章與檢驗。本篇文章使用 Web3.js 4.1.1 版本。

簽章

Web3.js 提供幾種鏈下簽章的函式,本篇文章介紹下面三種:

  1. web3.eth.sign
  2. web3.eth.accounts.sign
  3. web3.personal.sign

關於 EIP-712 相關的方法,參考使用 Web3.js 進行 EIP-712 類型結構化資料簽名

web3.eth.sign

這是早期使用的函式,簽章不使用前綴。

1
web3.eth.sign(message: Bytes, address: string): Promise<string>
  • message: 簽署的內容,只能是 32 bytes 長度的二進位內容。可以是 Uint8Array 或 0x 開頭的字串。
  • address: 簽署人地址。
  • 回傳值: 為 Promise 物件,內容為簽章。

Metamask 已經停用了這個功能,使用會在 console 看到錯誤訊息:

1
inpage.js:1 MetaMask - RPC Error: eth_sign has been disabled. You must enable it in the advanced settings {code: -32601, message: 'eth_sign has been disabled. You must enable it in the advanced settings'}

如果要使用要在 Metamask 中開啟設定:
[設定] => [進階] => [Eth_sign requests]
Eth_sign requests

開啟輸入 I only sign what I understand
I only sign what I understand

再次嘗試簽章,例如執行以下程式:

1
2
3
4
5
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const account = '0xDD980c315dFA75682F04381E98ea38BD2A151540';

// 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a356320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f81b
const signature = await web3.eth.sign(message, account);

簽署過程可以看到簽署的內容為一串雜湊
簽署

因為這個方法不建議使用所以每次簽署都會跳警告
簽署警告

可以利用套件切開簽章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { fromRpcSig } from 'ethereumjs-util';

// {
// v: 27,
// r: <Buffer e1 07 7f b9 32 1c 18 7d 8a 43 92 68 96 ab ac 54 55 ce 6a dd 26 9e 09 8f 85 5f f0 59 d6 b8 46 a3>,
// s: <Buffer 56 32 0b e5 f6 d7 9c 4d 0e 55 83 d6 b9 a2 e5 0f ae 78 f1 fb 5f f0 55 35 41 e6 9c 66 da e2 b2 f8>
// }
const result = fromRpcSig(signature);

// {
// v: '0x1b',
// r: '0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3',
// s: '0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8'
// }
const vrs = {
v: `0x${result.v.toString(16)}`,
r: `0x${result.r.toString('hex')}`,
s: `0x${result.s.toString('hex')}`
};

對應到智能合約的驗證,使用的是第一個範例無前綴的方式

1
2
3
4
5
6
7
bytes32 hash = 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53;
uint8 v = 0x1b;
bytes32 r = 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3;
bytes32 s = 0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8;

// 0xDD980c315dFA75682F04381E98ea38BD2A151540
return ecrecover(hash , v, r, s);

web3.eth.accounts.sign

私鑰簽署使用這函式,會加上 EIP-191 前綴。

1
web3.eth.accounts.sign(data: string, privateKey: Bytes): SignResult
  • data: 簽署的內容,0x 開頭為 bytes 二進位資料,或是文字內容。
  • privateKey: 簽署人私鑰。可以是 Uint8Array 或 0x 開頭的字串。
  • 回傳值: 簽章物件,內容參考下面例子。

加上前綴,簽署內容為

1
“\x19Ethereum Signed Message:\n” + message.length + message

例如我們簽署一樣內容

1
2
3
4
5
6
7
8
9
10
11
12
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const privateKey = '0x....';

// {
// message: '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53',
// messageHash: '0x69a1658d9d5e169959e0e2acec0eb76ec15549ebc0680f45cfc25c4a3ca952e6',
// v: '0x1c',
// r: '0x3105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f3',
// s: '0x29ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a',
// signature: '0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f30029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a1c'
// }
const signature = web3.eth.accounts.sign(message, privateKey);

會發現簽章結果和上面不同,另外回傳的格式也不同。同時我們還發現這邊有個 bug,回傳的 r 和 s 前面沒有補 0,正確應該是:

1
2
0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f3
0x0029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a

在 v4.1.1 有這個 bug,舊版的 1.x 是正常的。我們可以先拿 signature 欄位來使用。對應在智能合約使用前綴的方式來檢驗:

1
2
3
bytes32 messageHash = 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53;
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));
return ecrecover(hash , v, r, s);

我有發 PR 修正個這 Bug,4.1.2 以上應該沒問題了。

或是簽署純文字內容

1
2
3
4
5
6
7
8
9
10
11
const message = 'OK!';

// {
// message: 'OK!',
// messageHash: '0x9632976ae3bb56254c6cc1a35ad3d11e7a7040fb6dde433ad2a1249afaf062f3',
// v: '0x1c',
// r: '0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c',
// s: '0x7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb2',
// signature: '0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb21c'
// }
const signature = await web3.eth.accounts.sign(message, privateKey);

智能合約

1
2
3
string memory message = "OK!";
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n3", message));
return ecrecover(hash , v, r, s);

注意文字如果有 UTF-8 的話,在鏈上的簽署的長度要是 bytes 長度。例如:

1
2
// 6
new Blob(['中文']).size

web3.eth.personal.sign

瀏覽器錢包簽署使用這函式,會加上 EIP-191 前綴。

1
web3.eth.personal.sign(data: string, address: string, passphrase: string): Promise<string>
  • data: 簽署的內容,0x 開頭為 bytes 二進位資料,或是文字內容。
  • address: 簽署人地址。
  • passphrase: 錢包密碼,可以不用填。
  • 回傳值: 為 Promise 物件,內容為簽章。

前綴規則同 web3.eth.accounts.sign,不重複說明。

例如執行以下簽章:

1
2
3
4
5
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const account = '0xDD980c315dFA75682F04381E98ea38BD2A151540';

// 0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f30029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a1c
const signature = await web3.eth.personal.sign(message, account, '');

會跳出簽章訊息如下
簽署

簽署結果和 web3.eth.accounts.sign 一樣,只是回傳的格式不同。同樣嘗試文字

1
2
3
4
const message = 'OK!';

// 0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb21c
const signature = await web3.eth.personal.sign(message, account, '');

會跳出簽章訊息如下
簽署純文字

但如果是 0x 開頭的資料,不過長度不是 32 Bytes

1
2
3
// 截掉最後 1 Byte
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e';
const signature = await web3.eth.personal.sign(message, account, '');

結果會變成一串亂碼
簽署亂碼

智能合約的檢驗也和 web3.eth.accounts.sign 一樣,不重複說明。

簽章格式

上面簽章有切開和未切開的兩種格式,切開就是照固定長度切開,前面 32 bytes 為 r,中間 32 bytes 為 s,最後 1 byte 為 v。拿上面的例子來看:

signature: 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a356320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f81b

r: 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3
s: 0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8
v: 0x1b

所以要切開簽章除了使用上面提的套件之外,也可以自己簡單的處理:

1
2
3
4
5
6
7
function splitSignature(signature) {
return {
r: `0x${signature.substring(2, 66)}`,
s: `0x${signature.substring(66, 130)}`,
v: `0x${signature.substring(130, 132)}`
}
}

檢驗簽章

除了在智能合約上的檢驗之外,我們也可以使用 Web3.js 來檢驗,可以作為後端程式登入之類的用途。可以用下面的函式:

  1. web3.eth.accounts.recover
  2. web3.eth.personal.ecRecover

web3.eth.accounts.recover

這個函式支援多種驗證方式,可以輸入切開或為切開的簽章,有前綴或沒前綴都可以。有多種輸入方式

1
web3.eth.accounts.recover(data: string, v: string, r: string, s: string, prefixed:? boolean): string
  • data: 簽署的內容,0x 開頭為 bytes 二進位資料,或是文字內容。
  • v: 0x 開頭的字串。
  • r: 0x 開頭的字串。
  • s: 0x 開頭的字串。
  • prefixed: true 為沒有前綴,false 為使用前綴。預設 false
  • 回傳值: 簽章人地址。

或是未切開的簽章

1
web3.eth.accounts.recover(data: string, signature: string, prefixed:? boolean): string
  • data: 簽署的內容,0x 開頭為 bytes 二進位資料,或是文字內容。
  • signature: 未切開的簽章,0x 開頭的字串。
  • prefixed: true 為沒有前綴,false 為使用前綴。預設 false
  • 回傳值: 簽章人地址。

或是物件

1
2
3
4
5
6
7
8
web3.eth.accounts.recover(data: SignatureObject, _: string, prefixed:? boolean): string

SignatureObject: {
messageHash: string;
r: string;
s: string;
v: string
}
  • data: 包含簽署內容和 vrs。
  • _: 無作用
  • prefixed: true 為沒有前綴,false 為使用前綴。預設 false
  • 回傳值: 簽章人地址。

對應 web3.eth.sign 之檢驗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const v = '0x1b';
const r = '0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3';
const s = '0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8';
// 0xDD980c315dFA75682F04381E98ea38BD2A151540
const signer = web3.eth.accounts.recover(message, v, r, s, true);

// or
const signature = '0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a356320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f81b';
const signer = web3.eth.accounts.recover(message, signature, true);

// or
const signatureObj = {
v, r, s,
messageHash: message
};
const signer = web3.eth.accounts.recover(signatureObj, '', true);

對應 web3.eth.accounts.sign 和 web3.eth.personal.sign 之檢驗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const v = '0x1c';
const r = '0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f3';
const s = '0x0029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a';
const signer = web3.eth.accounts.recover(message, v, r, s);

// or
const signature = '0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f30029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a1c';
const signer = web3.eth.accounts.recover(message, signature);

// or
const signatureObj = {
v, r, s,
messageHash: message
};
const signer = web3.eth.accounts.recover(signatureObj);

純文字的簽署內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const message = 'OK!';
const v = '0x1c';
const r = '0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c';
const s = '0x7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb2';
const signer = web3.eth.accounts.recover(message, v, r, s);

// or
const signature = '0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb21c';
const signer = web3.eth.accounts.recover(message, signature);

// or
const signatureObj = {
v, r, s,
messageHash: message
};
const signer = web3.eth.accounts.recover(signatureObj);

web3.eth.personal.ecRecover

這個函式只支援未切開的簽章,固定使用 EIP-191 前綴。瀏覽器錢包才能用。

1
web3.eth.personal.ecRecover(signedData: string, signature: string): Promise<string>
  • signedData: 簽署的內容,0x 開頭為 bytes 二進位資料,或是文字內容。
  • signature: 未切開的簽章,0x 開頭的字串。
  • 回傳值: 為 Promise 物件,內容為簽章人地址。

對應 web3.eth.accounts.sign 和 web3.eth.personal.sign 之檢驗

1
2
3
4
const message = '0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53';
const signature = '0x03105c6f9bcc43236f5ef5e78038506c2551fc11d65701097295094c06c9d4f30029ec14347d37d8115cebcf915dc8f6f74c011698d9b7aea15c184f2277197a1c';
// 0xdd980c315dfa75682f04381e98ea38bd2a151540
const signer = await web3.eth.personal.ecRecover(message, signature);

純文字的簽署內容

1
2
3
4
const message = 'OK!';
const signature = '0x7dff4feac0d0bf31d5b11ef3793bf9d00e7b9028eaaa9737f5079685ff435c9c7a22e79b7dab0b0d37524a0f9621d98b76fbc77b6402f87d142746eee61edcb21c';
// 0xdd980c315dfa75682f04381e98ea38bd2a151540
const signer = await web3.eth.personal.ecRecover(message, signature);

簽名延展性 (Signature Malleability)

Web3.js 同樣有簽名延展性 (Signature Malleability) 問題,一樣可以檢查 s 的值:

1
2
3
if (s > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0n) {
throw new Error('invalid s');
}

總結

  1. web3.eth.sign 已過時不使用。
  2. 私鑰簽署使用 web3.eth.accounts.sign
  3. 瀏覽器錢包簽署使用 web3.personal.sign
  4. 檢驗簽章使用 web3.eth.accounts.recover 比較通用。

延伸閱讀

Solidity 智能合約 - ecrecover 簽章檢驗
Solidity 智能合約 - 簽名延展性 (Signature Malleability)
使用 Ethers.js 簽章與檢驗
使用 Web3.js 進行 EIP-712 類型結構化資料簽名