簡介

智能合約中的 ecrecover 函式,可以用來驗證錢包的的簽章。檢驗方可以在沒有簽章者私鑰的情況下,確認簽署者的身份。常用來作為鏈下授權,再由第三方上鏈使用或是單純的身份驗證。

函式

1
function ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
  • hash: 是被簽章訊息的雜湊
  • v: 簽章的 v 值
  • r: 簽章的 r 值
  • s: 簽章的 s 值
  • 回傳: 簽章者地址

範例

例如下面的範例

1
2
3
4
5
6
7
8
9
contract EcrecoverTest {
function test() external view returns(address) {
bytes32 hash = 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53;
uint8 v = 0x1b;
bytes32 r = 0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a3;
bytes32 s = 0x56320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f8;
return ecrecover(hash , v, r, s);
}
}

結果會回傳簽署者錢包地址 0xDD980c315dFA75682F04381E98ea38BD2A151540,表示他簽署了 0xc1af4b94166cd32fc49b7b926cbb91ee421de2d04450e8ae57857b9b56ac7e53 這個訊息。

正常使用時,是用參數傳入而不是寫死,並檢驗簽章者,例如:

1
2
3
function verify(address owner, bytes32 hash, uint8 v, bytes32 r, bytes32 s) external {
require(owner == ecrecover(hash , v, r, s), "invalid signature");
}

不過直接把 hash 傳入不能看出他授權的內容,所以實際上會是一些原始資料,並在鏈上進行雜湊:

1
2
3
4
5
function permit(address owner, uint value, uint8 v, bytes32 r, bytes32 s) external {
bytes32 hash = keccak256(abi.encodePacked(value));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
// 例如 value 是使用者授權可動用的金額
}

EIP-191

除了上面的方式,在 EIP-191 定義了在使用簽章時,要加上一段前綴:

1
"\x19Ethereum Signed Message:\n" + 訊息 bytes 長度 + 訊息內容

uint 是 32 bytes,所以上面的例子可以改為:

1
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", value));

這是因為原來簽章方式,有可能產生有效的交易簽章。

重放 (Replay)

意思是指同樣的簽章被重複使用,在沒有正確處理簽章的使用範圍和有效狀態,可能被利用來做為一種攻擊的手段。上面的例子可以看出沒有處理,所以可以重複使用簽章來動用資產,一般我們可以加上一個 nonce 來判斷此簽章是否已經使用過:

1
2
3
4
5
6
7
8
9
10
11
contract EcrecoverTest {
mapping(uint => bool) usedNonces;

function permit(address owner, uint value, uint nonce, uint8 v, bytes32 r, bytes32 s) external {
require(!usedNonces[nonce], "used nonce");
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n64", value, nonce));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
usedNonces[nonce] = true;
// ...
}
}

nonce 也可以是在鏈上每個使用者有的一個遞增數值。除了 nonce 之外,一般還會加上一個有效時間。不過即使加上了 nonce 和有效時間,仍然存在簽章重複使用的可能,在不同的合約如果使用和上面相同驗證方式,相同的簽章會可以重複使用,這時候可以加上合約地址限制使用的範圍。在做了上述的限制和處理後,最後還是有一個情況可能發生重放,就是在不同的鏈假設剛好有相同的合約地址,雖然機率很低。最後我們可以加上鏈 ID 和授權對象做限制得到以下範例:

1
2
3
4
5
6
7
8
function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
require(deadline >= block.timestamp, "signature expired");
require(!usedNonces[nonce], "used nonce");
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n168", block.chainid, address(this), spender, value, nonce, deadline));
require(owner == ecrecover(hash , v, r, s), "invalid signature");
usedNonces[nonce] = true;
// ...
}

雜湊簽章訊息

由於前綴需要輸入訊息長度,要去計算動態長度的訊息,或許處理會有點麻煩,所以也有一個方式是簽署的訊息是先進行過一次雜湊:

1
2
bytes32 messageHash = abi.encodePacked(block.chainid, address(this), spender, value, nonce, deadline);
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));

這後後面就可以固定寫 32。

簽章擷取

簽章格式除了已經擷取好的 vrs 之外,有時也會有沒有擷取的

1
0xe1077fb9321c187d8a43926896abac5455ce6add269e098f855ff059d6b846a356320be5f6d79c4d0e5583d6b9a2e50fae78f1fb5ff0553541e69c66dae2b2f81b

在鏈上可以用以下方式擷取出 vrs

1
2
3
4
5
6
7
8
9
function splitSignature(bytes memory signature) internal pure returns (uint8 v, bytes32 r, bytes32 s) {
require(signature.length == 65, "invalid signature");
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
return (v, r, s);
}

不過並不建議在鏈上擷取,會造成 Gas 的上升,應該在鏈下先進行擷取。

取消簽章

由於簽章一但簽署送出後,在失效之前持有者都有權限執行相關的操作,可能會造成簽章者的損失。例如常見的使用簽章來掛單報價,假設一個 NFT 持有者簽署了以 1 ETH 賣出,有效一個月。後來預期價格上漲,持有者在網頁上取消掛單,但如果鏈上沒有取消簽章的機制,一個月內知道簽章的人仍能以 1 ETH 買入,即便市價漲到 10 ETH。之前 OpenSea 就曾發生相關事件

為了掛單時節省成本和提升效率,使用鏈下簽章的報價機制,反而造成上述的問題。要解決這個問題,就必須使用鏈上取消簽章的機制,將上面的範例簡單改為讓 nonce 無效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
contract EcrecoverTest {
// 先改為每個地址管理自己的 nonce
mapping(address => mapping(uint => bool)) usedNonces;

function cancelNonce(uint nonce) external {
usedNonces[msg.sender][nonce] = true;
}

function permit(address owner, address spender, uint value, uint nonce, uint deadline, uint8 v, bytes32 r, bytes32 s) external {
// ...
require(!usedNonces[owner][nonce], "used nonce");
// ...
}
}

使用套件

我們也可以使用 OpenZeppelin MessageHashUtils 套件 來處理簽署訊息的前綴。我們可以利用下面的多載方法自動加上 EIP-191 前綴:

1
2
function toEthSignedMessageHash(bytes32 messageHash) internal pure returns (bytes32 digest);
function toEthSignedMessageHash(bytes memory message) internal pure returns (bytes32 digest);

固定長度雜湊簽章訊息

1
2
3
4
5
6
7
8
9
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

// bytes32 messageHash = ...;

// 原本寫法
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash));

// 使用套件
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(messageHash);

動態長度的內容:

1
2
3
4
5
6
7
8
// uint value = ...;
// uint nonce = ...;

// 原本寫法
bytes32 hash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n64", value, nonce));

// 使用套件
bytes32 hash = MessageHashUtils.toEthSignedMessageHash(abi.encodePacked(value, nonce));

延伸閱讀

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