Browse Source

Add EIP 712 helpers (#2418)

Francisco Giordano 4 years ago
parent
commit
5748034cd3

+ 2 - 1
CHANGELOG.md

@@ -2,7 +2,8 @@
 
 ## Unreleased
 
- * Add beacon proxy. ([#2411](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2411))
+ * `BeaconProxy`: added new kind of proxy that allows simultaneous atomic upgrades. ([#2411](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2411))
+ * `EIP712`: added helpers to verify EIP712 typed data signatures on chain. ([#2418](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2418))
  * `Address`: added `functionDelegateCall`, similar to the existing `functionCall`. ([#2333](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/2333))
 
 ## 3.3.0 (2020-11-26)

+ 4 - 0
contracts/cryptography/README.adoc

@@ -5,6 +5,10 @@ NOTE: This document is better viewed at https://docs.openzeppelin.com/contracts/
 
 This collection of libraries provides simple and safe ways to use different cryptographic primitives.
 
+The following related EIPs are in draft status and can be found in the drafts directory.
+
+- {EIP712}
+
 == Libraries
 
 {{ECDSA}}

+ 105 - 0
contracts/drafts/EIP712.sol

@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity >=0.6.0 <0.8.0;
+
+/**
+ * @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data.
+ *
+ * The encoding specified in the EIP is very generic, and such a generic implementation in Solidity is not feasible,
+ * thus this contract does not implement the encoding itself. Protocols need to implement the type-specific encoding
+ * they need in their contracts using a combination of `abi.encode` and `keccak256`.
+ *
+ * This contract implements the EIP 712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding
+ * scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA
+ * ({_hashTypedDataV4}).
+ *
+ * The implementation of the domain separator was designed to be as efficient as possible while still properly updating
+ * the chain id to protect against replay attacks on an eventual fork of the chain.
+ *
+ * NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method
+ * https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask].
+ */
+abstract contract EIP712 {
+    /* solhint-disable var-name-mixedcase */
+    // Cache the domain separator as an immutable value, but also store the chain id that it corresponds to, in order to
+    // invalidate the cached domain separator if the chain id changes.
+    bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
+    uint256 private immutable _CACHED_CHAIN_ID;
+
+    bytes32 private immutable _HASHED_NAME;
+    bytes32 private immutable _HASHED_VERSION;
+    bytes32 private immutable _TYPE_HASH;
+    /* solhint-enable var-name-mixedcase */
+
+    /**
+     * @dev Initializes the domain separator and parameter caches.
+     *
+     * The meaning of `name` and `version` is specified in
+     * https://eips.ethereum.org/EIPS/eip-712#definition-of-domainseparator[EIP 712]:
+     *
+     * - `name`: the user readable name of the signing domain, i.e. the name of the DApp or the protocol.
+     * - `version`: the current major version of the signing domain.
+     *
+     * NOTE: These parameters cannot be changed except through a xref:learn::upgrading-smart-contracts.adoc[smart
+     * contract upgrade].
+     */
+    constructor(string memory name, string memory version) internal {
+        bytes32 hashedName = keccak256(bytes(name));
+        bytes32 hashedVersion = keccak256(bytes(version));
+        bytes32 typeHash = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); 
+        _HASHED_NAME = hashedName;
+        _HASHED_VERSION = hashedVersion;
+        _CACHED_CHAIN_ID = _getChainId();
+        _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion);
+        _TYPE_HASH = typeHash;
+    }
+
+    /**
+     * @dev Returns the domain separator for the current chain.
+     */
+    function _domainSeparatorV4() internal view returns (bytes32) {
+        if (_getChainId() == _CACHED_CHAIN_ID) {
+            return _CACHED_DOMAIN_SEPARATOR;
+        } else {
+            return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION);
+        }
+    }
+
+    function _buildDomainSeparator(bytes32 typeHash, bytes32 name, bytes32 version) private view returns (bytes32) {
+        return keccak256(
+            abi.encode(
+                typeHash,
+                name,
+                version,
+                _getChainId(),
+                address(this)
+            )
+        );
+    }
+
+    /**
+     * @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this
+     * function returns the hash of the fully encoded EIP712 message for this domain.
+     *
+     * This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example:
+     *
+     * ```solidity
+     * bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
+     *     keccak256("Mail(address to,string contents)"),
+     *     mailTo,
+     *     keccak256(bytes(mailContents))
+     * )));
+     * address signer = ECDSA.recover(digest, signature);
+     * ```
+     */
+    function _hashTypedDataV4(bytes32 structHash) internal view returns (bytes32) {
+        return keccak256(abi.encodePacked("\x19\x01", _domainSeparatorV4(), structHash));
+    }
+
+    function _getChainId() private pure returns (uint256 chainId) {
+        // solhint-disable-next-line no-inline-assembly
+        assembly {
+            chainId := chainid()
+        }
+    }
+}

+ 9 - 0
contracts/drafts/README.adoc

@@ -0,0 +1,9 @@
+= Draft EIPS
+
+This directory contains implementations of EIPs that are still in Draft status.
+
+Due to their nature as drafts, the details of these contracts may change and we cannot guarantee their xref:ROOT:releases-stability.adoc[stability]. Minor releases of OpenZeppelin Contracts may contain breaking changes for the contracts in this directory, which will be duly announced in the https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md[changelog]. The EIPs included here are used by projects in production and this may make them less likely to change significantly.
+
+== Cryptography
+
+{{EIP712}}

+ 31 - 0
contracts/mocks/EIP712External.sol

@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity >=0.6.0 <0.8.0;
+
+import "../drafts/EIP712.sol";
+import "../cryptography/ECDSA.sol";
+
+contract EIP712External is EIP712 {
+    constructor(string memory name, string memory version) public EIP712(name, version) {}
+
+    function domainSeparator() external view returns (bytes32) {
+        return _domainSeparatorV4();
+    }
+
+    function verify(bytes memory signature, address signer, address mailTo, string memory mailContents) external view {
+        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
+            keccak256("Mail(address to,string contents)"),
+            mailTo,
+            keccak256(bytes(mailContents))
+        )));
+        address recoveredSigner = ECDSA.recover(digest, signature);
+        require(recoveredSigner == signer);
+    }
+
+    function getChainId() external pure returns (uint256 chainId) {
+        // solhint-disable-next-line no-inline-assembly
+        assembly {
+            chainId := chainid()
+        }
+    }
+}

+ 143 - 24
package-lock.json

@@ -555,6 +555,62 @@
           "integrity": "sha512-LEHHyuhlPY3TmuUYMh2oz89lTShfvgbmzaBcxve9t/9Wuy7Dwf4yoAKcND7KFT1HAQfqZ12qtc+DUrBMeKF9nw==",
           "dev": true
         },
+        "eth-sig-util": {
+          "version": "2.5.2",
+          "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-2.5.2.tgz",
+          "integrity": "sha512-xvDojS/4reXsw8Pz/+p/qcM5rVB61FOdPbEtMZ8FQ0YHnPEzPy5F8zAAaZ+zj5ud0SwRLWPfor2Cacjm7EzMIw==",
+          "dev": true,
+          "requires": {
+            "buffer": "^5.2.1",
+            "elliptic": "^6.4.0",
+            "ethereumjs-abi": "0.6.5",
+            "ethereumjs-util": "^5.1.1",
+            "tweetnacl": "^1.0.0",
+            "tweetnacl-util": "^0.15.0"
+          },
+          "dependencies": {
+            "ethereumjs-abi": {
+              "version": "0.6.5",
+              "resolved": "https://registry.npmjs.org/ethereumjs-abi/-/ethereumjs-abi-0.6.5.tgz",
+              "integrity": "sha1-WmN+8Wq0NHP6cqKa2QhxQFs/UkE=",
+              "dev": true,
+              "requires": {
+                "bn.js": "^4.10.0",
+                "ethereumjs-util": "^4.3.0"
+              },
+              "dependencies": {
+                "ethereumjs-util": {
+                  "version": "4.5.1",
+                  "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-4.5.1.tgz",
+                  "integrity": "sha512-WrckOZ7uBnei4+AKimpuF1B3Fv25OmoRgmYCpGsP7u8PFxXAmAgiJSYT2kRWnt6fVIlKaQlZvuwXp7PIrmn3/w==",
+                  "dev": true,
+                  "requires": {
+                    "bn.js": "^4.8.0",
+                    "create-hash": "^1.1.2",
+                    "elliptic": "^6.5.2",
+                    "ethereum-cryptography": "^0.1.3",
+                    "rlp": "^2.0.0"
+                  }
+                }
+              }
+            },
+            "ethereumjs-util": {
+              "version": "5.2.1",
+              "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-5.2.1.tgz",
+              "integrity": "sha512-v3kT+7zdyCm1HIqWlLNrHGqHGLpGYIhjeHxQjnDXjLT2FyGJDsd3LWMYUo7pAFRrk86CR3nUJfhC81CCoJNNGQ==",
+              "dev": true,
+              "requires": {
+                "bn.js": "^4.11.0",
+                "create-hash": "^1.1.2",
+                "elliptic": "^6.5.2",
+                "ethereum-cryptography": "^0.1.3",
+                "ethjs-util": "^0.1.3",
+                "rlp": "^2.0.0",
+                "safe-buffer": "^5.1.1"
+              }
+            }
+          }
+        },
         "ethereumjs-util": {
           "version": "6.2.1",
           "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-6.2.1.tgz",
@@ -1458,12 +1514,74 @@
         "web3-utils": "^1.2.1"
       },
       "dependencies": {
+        "aes-js": {
+          "version": "3.1.2",
+          "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
+          "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
+          "dev": true
+        },
         "bignumber.js": {
           "version": "9.0.1",
           "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.1.tgz",
           "integrity": "sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==",
           "dev": true
         },
+        "eth-sig-util": {
+          "version": "2.5.2",
+          "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-2.5.2.tgz",
+          "integrity": "sha512-xvDojS/4reXsw8Pz/+p/qcM5rVB61FOdPbEtMZ8FQ0YHnPEzPy5F8zAAaZ+zj5ud0SwRLWPfor2Cacjm7EzMIw==",
+          "dev": true,
+          "requires": {
+            "buffer": "^5.2.1",
+            "elliptic": "^6.4.0",
+            "ethereumjs-abi": "0.6.5",
+            "ethereumjs-util": "^5.1.1",
+            "tweetnacl": "^1.0.0",
+            "tweetnacl-util": "^0.15.0"
+          },
+          "dependencies": {
+            "ethereumjs-util": {
+              "version": "5.2.1",
+              "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-5.2.1.tgz",
+              "integrity": "sha512-v3kT+7zdyCm1HIqWlLNrHGqHGLpGYIhjeHxQjnDXjLT2FyGJDsd3LWMYUo7pAFRrk86CR3nUJfhC81CCoJNNGQ==",
+              "dev": true,
+              "requires": {
+                "bn.js": "^4.11.0",
+                "create-hash": "^1.1.2",
+                "elliptic": "^6.5.2",
+                "ethereum-cryptography": "^0.1.3",
+                "ethjs-util": "^0.1.3",
+                "rlp": "^2.0.0",
+                "safe-buffer": "^5.1.1"
+              }
+            }
+          }
+        },
+        "ethereumjs-abi": {
+          "version": "0.6.5",
+          "resolved": "https://registry.npmjs.org/ethereumjs-abi/-/ethereumjs-abi-0.6.5.tgz",
+          "integrity": "sha1-WmN+8Wq0NHP6cqKa2QhxQFs/UkE=",
+          "dev": true,
+          "requires": {
+            "bn.js": "^4.10.0",
+            "ethereumjs-util": "^4.3.0"
+          },
+          "dependencies": {
+            "ethereumjs-util": {
+              "version": "4.5.1",
+              "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-4.5.1.tgz",
+              "integrity": "sha512-WrckOZ7uBnei4+AKimpuF1B3Fv25OmoRgmYCpGsP7u8PFxXAmAgiJSYT2kRWnt6fVIlKaQlZvuwXp7PIrmn3/w==",
+              "dev": true,
+              "requires": {
+                "bn.js": "^4.8.0",
+                "create-hash": "^1.1.2",
+                "elliptic": "^6.5.2",
+                "ethereum-cryptography": "^0.1.3",
+                "rlp": "^2.0.0"
+              }
+            }
+          }
+        },
         "ethereumjs-tx": {
           "version": "1.3.7",
           "resolved": "https://registry.npmjs.org/ethereumjs-tx/-/ethereumjs-tx-1.3.7.tgz",
@@ -1505,6 +1623,23 @@
             "ethjs-util": "0.1.6",
             "rlp": "^2.2.3"
           }
+        },
+        "ethereumjs-wallet": {
+          "version": "0.6.5",
+          "resolved": "https://registry.npmjs.org/ethereumjs-wallet/-/ethereumjs-wallet-0.6.5.tgz",
+          "integrity": "sha512-MDwjwB9VQVnpp/Dc1XzA6J1a3wgHQ4hSvA1uWNatdpOrtCbPVuQSKSyRnjLvS0a+KKMw2pvQ9Ybqpb3+eW8oNA==",
+          "dev": true,
+          "requires": {
+            "aes-js": "^3.1.1",
+            "bs58check": "^2.1.2",
+            "ethereum-cryptography": "^0.1.3",
+            "ethereumjs-util": "^6.0.0",
+            "randombytes": "^2.0.6",
+            "safe-buffer": "^5.1.2",
+            "scryptsy": "^1.2.1",
+            "utf8": "^3.0.0",
+            "uuid": "^3.3.2"
+          }
         }
       }
     },
@@ -5440,9 +5575,9 @@
       }
     },
     "eth-sig-util": {
-      "version": "2.5.2",
-      "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-2.5.2.tgz",
-      "integrity": "sha512-xvDojS/4reXsw8Pz/+p/qcM5rVB61FOdPbEtMZ8FQ0YHnPEzPy5F8zAAaZ+zj5ud0SwRLWPfor2Cacjm7EzMIw==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/eth-sig-util/-/eth-sig-util-3.0.0.tgz",
+      "integrity": "sha512-4eFkMOhpGbTxBQ3AMzVf0haUX2uTur7DpWiHzWyTURa28BVJJtOkcb9Ok5TV0YvEPG61DODPW7ZUATbJTslioQ==",
       "dev": true,
       "requires": {
         "buffer": "^5.2.1",
@@ -5824,18 +5959,17 @@
       }
     },
     "ethereumjs-wallet": {
-      "version": "0.6.5",
-      "resolved": "https://registry.npmjs.org/ethereumjs-wallet/-/ethereumjs-wallet-0.6.5.tgz",
-      "integrity": "sha512-MDwjwB9VQVnpp/Dc1XzA6J1a3wgHQ4hSvA1uWNatdpOrtCbPVuQSKSyRnjLvS0a+KKMw2pvQ9Ybqpb3+eW8oNA==",
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/ethereumjs-wallet/-/ethereumjs-wallet-1.0.1.tgz",
+      "integrity": "sha512-3Z5g1hG1das0JWU6cQ9HWWTY2nt9nXCcwj7eXVNAHKbo00XAZO8+NHlwdgXDWrL0SXVQMvTWN8Q/82DRH/JhPw==",
       "dev": true,
       "requires": {
         "aes-js": "^3.1.1",
         "bs58check": "^2.1.2",
         "ethereum-cryptography": "^0.1.3",
-        "ethereumjs-util": "^6.0.0",
+        "ethereumjs-util": "^7.0.2",
         "randombytes": "^2.0.6",
-        "safe-buffer": "^5.1.2",
-        "scryptsy": "^1.2.1",
+        "scrypt-js": "^3.0.1",
         "utf8": "^3.0.0",
         "uuid": "^3.3.2"
       },
@@ -5845,21 +5979,6 @@
           "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz",
           "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==",
           "dev": true
-        },
-        "ethereumjs-util": {
-          "version": "6.2.1",
-          "resolved": "https://registry.npmjs.org/ethereumjs-util/-/ethereumjs-util-6.2.1.tgz",
-          "integrity": "sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==",
-          "dev": true,
-          "requires": {
-            "@types/bn.js": "^4.11.3",
-            "bn.js": "^4.11.0",
-            "create-hash": "^1.1.2",
-            "elliptic": "^6.5.2",
-            "ethereum-cryptography": "^0.1.3",
-            "ethjs-util": "0.1.6",
-            "rlp": "^2.2.3"
-          }
         }
       }
     },

+ 2 - 0
package.json

@@ -60,7 +60,9 @@
     "eslint-plugin-node": "^10.0.0",
     "eslint-plugin-promise": "^4.2.1",
     "eslint-plugin-standard": "^4.0.1",
+    "eth-sig-util": "^3.0.0",
     "ethereumjs-util": "^7.0.7",
+    "ethereumjs-wallet": "^1.0.1",
     "lodash.startcase": "^4.4.0",
     "lodash.zip": "^4.2.0",
     "micromatch": "^4.0.2",

+ 70 - 0
test/drafts/EIP712.test.js

@@ -0,0 +1,70 @@
+const ethSigUtil = require('eth-sig-util');
+const Wallet = require('ethereumjs-wallet').default;
+
+const EIP712 = artifacts.require('EIP712External');
+
+const EIP712Domain = [
+  { name: 'name', type: 'string' },
+  { name: 'version', type: 'string' },
+  { name: 'chainId', type: 'uint256' },
+  { name: 'verifyingContract', type: 'address' },
+];
+
+async function domainSeparator (name, version, chainId, verifyingContract) {
+  return '0x' + ethSigUtil.TypedDataUtils.hashStruct(
+    'EIP712Domain',
+    { name, version, chainId, verifyingContract },
+    { EIP712Domain },
+  ).toString('hex');
+}
+
+contract('EIP712', function (accounts) {
+  const [mailTo] = accounts;
+
+  const name = 'A Name';
+  const version = '1';
+
+  beforeEach('deploying', async function () {
+    this.eip712 = await EIP712.new(name, version);
+
+    // We get the chain id from the contract because Ganache (used for coverage) does not return the same chain id
+    // from within the EVM as from the JSON RPC interface.
+    // See https://github.com/trufflesuite/ganache-core/issues/515
+    this.chainId = await this.eip712.getChainId();
+  });
+
+  it('domain separator', async function () {
+    expect(
+      await this.eip712.domainSeparator(),
+    ).to.equal(
+      await domainSeparator(name, version, this.chainId, this.eip712.address),
+    );
+  });
+
+  it('digest', async function () {
+    const chainId = this.chainId;
+    const verifyingContract = this.eip712.address;
+    const message = {
+      to: mailTo,
+      contents: 'very interesting',
+    };
+
+    const data = {
+      types: {
+        EIP712Domain,
+        Mail: [
+          { name: 'to', type: 'address' },
+          { name: 'contents', type: 'string' },
+        ],
+      },
+      domain: { name, version, chainId, verifyingContract },
+      primaryType: 'Mail',
+      message,
+    };
+
+    const wallet = Wallet.generate();
+    const signature = ethSigUtil.signTypedMessage(wallet.getPrivateKey(), { data });
+
+    await this.eip712.verify(signature, wallet.getAddressString(), message.to, message.contents);
+  });
+});