diff --git a/.changeset/nice-rings-wish.md b/.changeset/nice-rings-wish.md new file mode 100644 index 000000000..50777b552 --- /dev/null +++ b/.changeset/nice-rings-wish.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`ERC7913P256Verifier` and `ERC7913RSAVerifier`: Ready to use ERC-7913 verifiers that implement key verification for P256 (secp256r1) and RSA keys. diff --git a/.changeset/quiet-kiwis-feel.md b/.changeset/quiet-kiwis-feel.md new file mode 100644 index 000000000..19cb5c67a --- /dev/null +++ b/.changeset/quiet-kiwis-feel.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`SignerERC7913`: Abstract signer that verifies signatures using the ERC-7913 workflow. diff --git a/.changeset/social-walls-obey.md b/.changeset/social-walls-obey.md new file mode 100644 index 000000000..05da8adba --- /dev/null +++ b/.changeset/social-walls-obey.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`MultiSignerERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system. diff --git a/.changeset/sour-pens-shake.md b/.changeset/sour-pens-shake.md new file mode 100644 index 000000000..e64e92d0c --- /dev/null +++ b/.changeset/sour-pens-shake.md @@ -0,0 +1,5 @@ +--- +'openzeppelin-solidity': minor +--- + +`SignatureChecker`: Add support for ERC-7913 signatures alongside existing ECDSA and ERC-1271 signature verification. diff --git a/contracts/interfaces/IERC7913.sol b/contracts/interfaces/IERC7913.sol new file mode 100644 index 000000000..58e334095 --- /dev/null +++ b/contracts/interfaces/IERC7913.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +/** + * @dev Signature verifier interface. + */ +interface IERC7913SignatureVerifier { + /** + * @dev Verifies `signature` as a valid signature of `hash` by `key`. + * + * MUST return the bytes4 magic value IERC7913SignatureVerifier.verify.selector if the signature is valid. + * SHOULD return 0xffffffff or revert if the signature is not valid. + * SHOULD return 0xffffffff or revert if the key is empty + */ + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) external view returns (bytes4); +} diff --git a/contracts/mocks/account/AccountMock.sol b/contracts/mocks/account/AccountMock.sol index 573c7e164..052730802 100644 --- a/contracts/mocks/account/AccountMock.sol +++ b/contracts/mocks/account/AccountMock.sol @@ -17,6 +17,8 @@ import {SignerECDSA} from "../../utils/cryptography/signers/SignerECDSA.sol"; import {SignerP256} from "../../utils/cryptography/signers/SignerP256.sol"; import {SignerRSA} from "../../utils/cryptography/signers/SignerRSA.sol"; import {SignerERC7702} from "../../utils/cryptography/signers/SignerERC7702.sol"; +import {SignerERC7913} from "../../utils/cryptography/signers/SignerERC7913.sol"; +import {MultiSignerERC7913} from "../../utils/cryptography/signers/MultiSignerERC7913.sol"; abstract contract AccountMock is Account, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { /// Validates a user operation with a boolean signature. @@ -136,3 +138,34 @@ abstract contract AccountERC7579HookedMock is AccountERC7579Hooked { _installModule(MODULE_TYPE_VALIDATOR, validator, initData); } } + +abstract contract AccountMultiSignerMock is Account, MultiSignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor(bytes[] memory signers, uint64 threshold) { + _addSigners(signers); + _setThreshold(threshold); + } + + /// @inheritdoc ERC7821 + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} + +abstract contract AccountERC7913Mock is Account, SignerERC7913, ERC7739, ERC7821, ERC721Holder, ERC1155Holder { + constructor(bytes memory _signer) { + _setSigner(_signer); + } + + /// @inheritdoc ERC7821 + function _erc7821AuthorizedExecutor( + address caller, + bytes32 mode, + bytes calldata executionData + ) internal view virtual override returns (bool) { + return caller == address(entryPoint()) || super._erc7821AuthorizedExecutor(caller, mode, executionData); + } +} diff --git a/contracts/utils/cryptography/README.adoc b/contracts/utils/cryptography/README.adoc index 8e76f4e21..79b104373 100644 --- a/contracts/utils/cryptography/README.adoc +++ b/contracts/utils/cryptography/README.adoc @@ -17,6 +17,8 @@ A collection of contracts and libraries that implement various signature validat * {ERC7739}: An abstract contract to validate signatures following the rehashing scheme from {ERC7739Utils}. * {SignerECDSA}, {SignerP256}, {SignerRSA}: Implementations of an {AbstractSigner} with specific signature validation algorithms. * {SignerERC7702}: Implementation of {AbstractSigner} that validates signatures using the contract's own address as the signer, useful for delegated accounts following EIP-7702. + * {SignerERC7913}, {MultiSignerERC7913}: Implementations of {AbstractSigner} that validate signatures based on ERC-7913. Including a simple multisignature scheme. + * {ERC7913P256Verifier}, {ERC7913RSAVerifier}: Ready to use ERC-7913 signature verifiers for P256 and RSA keys. == Utils @@ -51,3 +53,13 @@ A collection of contracts and libraries that implement various signature validat {{SignerRSA}} {{SignerERC7702}} + +{{SignerERC7913}} + +{{MultiSignerERC7913}} + +== Verifiers + +{{ERC7913P256Verifier}} + +{{ERC7913RSAVerifier}} diff --git a/contracts/utils/cryptography/SignatureChecker.sol b/contracts/utils/cryptography/SignatureChecker.sol index 2ce479446..261372f0c 100644 --- a/contracts/utils/cryptography/SignatureChecker.sol +++ b/contracts/utils/cryptography/SignatureChecker.sol @@ -5,19 +5,29 @@ pragma solidity ^0.8.24; import {ECDSA} from "./ECDSA.sol"; import {IERC1271} from "../../interfaces/IERC1271.sol"; +import {IERC7913SignatureVerifier} from "../../interfaces/IERC7913.sol"; +import {Bytes} from "../../utils/Bytes.sol"; /** - * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support both ECDSA - * signatures from externally owned accounts (EOAs) as well as ERC-1271 signatures from smart contract wallets like - * Argent and Safe Wallet (previously Gnosis Safe). + * @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support: + * + * * ECDSA signatures from externally owned accounts (EOAs) + * * ERC-1271 signatures from smart contract wallets like Argent and Safe Wallet (previously Gnosis Safe) + * * ERC-7913 signatures from keys that do not have an Ethereum address of their own + * + * See https://eips.ethereum.org/EIPS/eip-1271[ERC-1271] and https://eips.ethereum.org/EIPS/eip-7913[ERC-7913]. */ library SignatureChecker { + using Bytes for bytes; + /** * @dev Checks if a signature is valid for a given signer and data hash. If the signer has code, the * signature is validated against it using ERC-1271, otherwise it's validated using `ECDSA.recover`. * * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus * change through time. It could return true at block N and false at block N+1 (or the opposite). + * + * NOTE: For an extended version of this function that supports ERC-7913 signatures, see {isValidERC7913SignatureNow}. */ function isValidSignatureNow(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) { if (signer.code.length == 0) { @@ -47,4 +57,79 @@ library SignatureChecker { result.length >= 32 && abi.decode(result, (bytes32)) == bytes32(IERC1271.isValidSignature.selector)); } + + /** + * @dev Verifies a signature for a given ERC-7913 signer and hash. + * + * The signer is a `bytes` object that is the concatenation of an address and optionally a key: + * `verifier || key`. A signer must be at least 20 bytes long. + * + * Verification is done as follows: + * + * * If `signer.length < 20`: verification fails + * * If `signer.length == 20`: verification is done using {isValidSignatureNow} + * * Otherwise: verification is done using {IERC7913SignatureVerifier} + * + * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus + * change through time. It could return true at block N and false at block N+1 (or the opposite). + */ + function isValidERC7913SignatureNow( + bytes memory signer, + bytes32 hash, + bytes memory signature + ) internal view returns (bool) { + if (signer.length < 20) { + return false; + } else if (signer.length == 20) { + return isValidSignatureNow(address(bytes20(signer)), hash, signature); + } else { + (bool success, bytes memory result) = address(bytes20(signer)).staticcall( + abi.encodeCall(IERC7913SignatureVerifier.verify, (signer.slice(20), hash, signature)) + ); + return (success && + result.length >= 32 && + abi.decode(result, (bytes32)) == bytes32(IERC7913SignatureVerifier.verify.selector)); + } + } + + /** + * @dev Verifies multiple ERC-7913 `signatures` for a given `hash` using a set of `signers`. + * Returns `false` if the number of signers and signatures is not the same. + * + * The signers should be ordered by their `keccak256` hash to ensure efficient duplication check. Unordered + * signers are supported, but the uniqueness check will be more expensive. + * + * NOTE: Unlike ECDSA signatures, contract signatures are revocable, and the outcome of this function can thus + * change through time. It could return true at block N and false at block N+1 (or the opposite). + */ + function areValidERC7913SignaturesNow( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures + ) internal view returns (bool) { + if (signers.length != signatures.length) return false; + + bytes32 lastId = bytes32(0); + + for (uint256 i = 0; i < signers.length; ++i) { + bytes memory signer = signers[i]; + + // If one of the signatures is invalid, reject the batch + if (!isValidERC7913SignatureNow(signer, hash, signatures[i])) return false; + + bytes32 id = keccak256(signer); + // If the current signer ID is greater than all previous IDs, then this is a new signer. + if (lastId < id) { + lastId = id; + } else { + // If this signer id is not greater than all the previous ones, verify that it is not a duplicate of a previous one + // This loop is never executed if the signers are ordered by id. + for (uint256 j = 0; j < i; ++j) { + if (id == keccak256(signers[j])) return false; + } + } + } + + return true; + } } diff --git a/contracts/utils/cryptography/signers/MultiSignerERC7913.sol b/contracts/utils/cryptography/signers/MultiSignerERC7913.sol new file mode 100644 index 000000000..a23c82dcb --- /dev/null +++ b/contracts/utils/cryptography/signers/MultiSignerERC7913.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.27; + +import {AbstractSigner} from "./AbstractSigner.sol"; +import {SignatureChecker} from "../SignatureChecker.sol"; +import {EnumerableSet} from "../../structs/EnumerableSet.sol"; + +/** + * @dev Implementation of {AbstractSigner} using multiple ERC-7913 signers with a threshold-based + * signature verification system. + * + * This contract allows managing a set of authorized signers and requires a minimum number of + * signatures (threshold) to approve operations. It uses ERC-7913 formatted signers, which + * makes it natively compatible with ECDSA and ERC-1271 signers. + * + * Example of usage: + * + * ```solidity + * contract MyMultiSignerAccount is Account, MultiSignerERC7913, Initializable { + * constructor() EIP712("MyMultiSignerAccount", "1") {} + * + * function initialize(bytes[] memory signers, uint64 threshold) public initializer { + * _addSigners(signers); + * _setThreshold(threshold); + * } + * + * function addSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _addSigners(signers); + * } + * + * function removeSigners(bytes[] memory signers) public onlyEntryPointOrSelf { + * _removeSigners(signers); + * } + * + * function setThreshold(uint64 threshold) public onlyEntryPointOrSelf { + * _setThreshold(threshold); + * } + * } + * ``` + * + * IMPORTANT: Failing to properly initialize the signers and threshold either during construction + * (if used standalone) or during initialization (if used as a clone) may leave the contract + * either front-runnable or unusable. + */ +abstract contract MultiSignerERC7913 is AbstractSigner { + using EnumerableSet for EnumerableSet.BytesSet; + using SignatureChecker for *; + + EnumerableSet.BytesSet private _signers; + uint64 private _threshold; + + /// @dev Emitted when a signer is added. + event ERC7913SignerAdded(bytes indexed signers); + + /// @dev Emitted when a signers is removed. + event ERC7913SignerRemoved(bytes indexed signers); + + /// @dev Emitted when the threshold is updated. + event ERC7913ThresholdSet(uint64 threshold); + + /// @dev The `signer` already exists. + error MultiSignerERC7913AlreadyExists(bytes signer); + + /// @dev The `signer` does not exist. + error MultiSignerERC7913NonexistentSigner(bytes signer); + + /// @dev The `signer` is less than 20 bytes long. + error MultiSignerERC7913InvalidSigner(bytes signer); + + /// @dev The `threshold` is unreachable given the number of `signers`. + error MultiSignerERC7913UnreachableThreshold(uint64 signers, uint64 threshold); + + /** + * @dev Returns a slice of the set of authorized signers. + * + * Using `start = 0` and `end = type(uint64).max` will return the entire set of signers. + * + * WARNING: Depending on the `start` and `end`, this operation can copy a large amount of data to memory, which + * can be expensive. This is designed for view accessors queried without gas fees. Using it in state-changing + * functions may become uncallable if the slice grows too large. + */ + function getSigners(uint64 start, uint64 end) public view virtual returns (bytes[] memory) { + return _signers.values(start, end); + } + + /// @dev Returns whether the `signer` is an authorized signer. + function isSigner(bytes memory signer) public view virtual returns (bool) { + return _signers.contains(signer); + } + + /// @dev Returns the minimum number of signers required to approve a multisignature operation. + function threshold() public view virtual returns (uint64) { + return _threshold; + } + + /** + * @dev Adds the `newSigners` to those allowed to sign on behalf of this contract. + * Internal version without access control. + * + * Requirements: + * + * * Each of `newSigners` must be at least 20 bytes long. Reverts with {MultiSignerERC7913InvalidSigner} if not. + * * Each of `newSigners` must not be authorized. See {isSigner}. Reverts with {MultiSignerERC7913AlreadyExists} if so. + */ + function _addSigners(bytes[] memory newSigners) internal virtual { + for (uint256 i = 0; i < newSigners.length; ++i) { + bytes memory signer = newSigners[i]; + require(signer.length >= 20, MultiSignerERC7913InvalidSigner(signer)); + require(_signers.add(signer), MultiSignerERC7913AlreadyExists(signer)); + emit ERC7913SignerAdded(signer); + } + } + + /** + * @dev Removes the `oldSigners` from the authorized signers. Internal version without access control. + * + * Requirements: + * + * * Each of `oldSigners` must be authorized. See {isSigner}. Otherwise {MultiSignerERC7913NonexistentSigner} is thrown. + * * See {_validateReachableThreshold} for the threshold validation. + */ + function _removeSigners(bytes[] memory oldSigners) internal virtual { + for (uint256 i = 0; i < oldSigners.length; ++i) { + bytes memory signer = oldSigners[i]; + require(_signers.remove(signer), MultiSignerERC7913NonexistentSigner(signer)); + emit ERC7913SignerRemoved(signer); + } + _validateReachableThreshold(); + } + + /** + * @dev Sets the signatures `threshold` required to approve a multisignature operation. + * Internal version without access control. + * + * Requirements: + * + * * See {_validateReachableThreshold} for the threshold validation. + */ + function _setThreshold(uint64 newThreshold) internal virtual { + _threshold = newThreshold; + _validateReachableThreshold(); + emit ERC7913ThresholdSet(newThreshold); + } + + /** + * @dev Validates the current threshold is reachable. + * + * Requirements: + * + * * The {signers}'s length must be `>=` to the {threshold}. Throws {MultiSignerERC7913UnreachableThreshold} if not. + */ + function _validateReachableThreshold() internal view virtual { + uint256 signersLength = _signers.length(); + uint64 currentThreshold = threshold(); + require( + signersLength >= currentThreshold, + MultiSignerERC7913UnreachableThreshold( + uint64(signersLength), // Safe cast. Economically impossible to overflow. + currentThreshold + ) + ); + } + + /** + * @dev Decodes, validates the signature and checks the signers are authorized. + * See {_validateSignatures} and {_validateThreshold} for more details. + * + * Example of signature encoding: + * + * ```solidity + * // Encode signers (verifier || key) + * bytes memory signer1 = abi.encodePacked(verifier1, key1); + * bytes memory signer2 = abi.encodePacked(verifier2, key2); + * + * // Order signers by their id + * if (keccak256(signer1) > keccak256(signer2)) { + * (signer1, signer2) = (signer2, signer1); + * (signature1, signature2) = (signature2, signature1); + * } + * + * // Assign ordered signers and signatures + * bytes[] memory signers = new bytes[](2); + * bytes[] memory signatures = new bytes[](2); + * signers[0] = signer1; + * signatures[0] = signature1; + * signers[1] = signer2; + * signatures[1] = signature2; + * + * // Encode the multi signature + * bytes memory signature = abi.encode(signers, signatures); + * ``` + * + * Requirements: + * + * * The `signature` must be encoded as `abi.encode(signers, signatures)`. + */ + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + if (signature.length == 0) return false; // For ERC-7739 compatibility + (bytes[] memory signers, bytes[] memory signatures) = abi.decode(signature, (bytes[], bytes[])); + return _validateThreshold(signers) && _validateSignatures(hash, signers, signatures); + } + + /** + * @dev Validates the signatures using the signers and their corresponding signatures. + * Returns whether whether the signers are authorized and the signatures are valid for the given hash. + * + * IMPORTANT: Sorting the signers by their `keccak256` hash will improve the gas efficiency of this function. + * See {SignatureChecker-areValidERC7913SignaturesNow} for more details. + * + * Requirements: + * + * * The `signatures` arrays must be at least as large as the `signers` arrays. Panics otherwise. + */ + function _validateSignatures( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures + ) internal view virtual returns (bool valid) { + for (uint256 i = 0; i < signers.length; ++i) { + if (!isSigner(signers[i])) { + return false; + } + } + return hash.areValidERC7913SignaturesNow(signers, signatures); + } + + /** + * @dev Validates that the number of signers meets the {threshold} requirement. + * Assumes the signers were already validated. See {_validateSignatures} for more details. + */ + function _validateThreshold(bytes[] memory validatingSigners) internal view virtual returns (bool) { + return validatingSigners.length >= threshold(); + } +} diff --git a/contracts/utils/cryptography/signers/SignerERC7913.sol b/contracts/utils/cryptography/signers/SignerERC7913.sol new file mode 100644 index 000000000..107b3959f --- /dev/null +++ b/contracts/utils/cryptography/signers/SignerERC7913.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.24; + +import {AbstractSigner} from "./AbstractSigner.sol"; +import {SignatureChecker} from "../SignatureChecker.sol"; + +/** + * @dev Implementation of {AbstractSigner} using + * https://eips.ethereum.org/EIPS/eip-7913[ERC-7913] signature verification. + * + * For {Account} usage, a {_setSigner} function is provided to set the ERC-7913 formatted {signer}. + * Doing so is easier for a factory, who is likely to use initializable clones of this contract. + * + * The signer is a `bytes` object that concatenates a verifier address and a key: `verifier || key`. + * + * Example of usage: + * + * ```solidity + * contract MyAccountERC7913 is Account, SignerERC7913, Initializable { + * function initialize(bytes memory signer_) public initializer { + * _setSigner(signer_); + * } + * } + * ``` + * + * IMPORTANT: Failing to call {_setSigner} either during construction (if used standalone) + * or during initialization (if used as a clone) may leave the signer either front-runnable or unusable. + */ + +abstract contract SignerERC7913 is AbstractSigner { + bytes private _signer; + + /// @dev Return the ERC-7913 signer (i.e. `verifier || key`). + function signer() public view virtual returns (bytes memory) { + return _signer; + } + + /// @dev Sets the signer (i.e. `verifier || key`) with an ERC-7913 formatted signer. + function _setSigner(bytes memory signer_) internal { + _signer = signer_; + } + + /// @dev Verifies a signature using {SignatureChecker-isValidERC7913SignatureNow} with {signer}, `hash` and `signature`. + function _rawSignatureValidation( + bytes32 hash, + bytes calldata signature + ) internal view virtual override returns (bool) { + return SignatureChecker.isValidERC7913SignatureNow(signer(), hash, signature); + } +} diff --git a/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol b/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol new file mode 100644 index 000000000..70d0f342a --- /dev/null +++ b/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {P256} from "../../../utils/cryptography/P256.sol"; +import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol"; + +/** + * @dev ERC-7913 signature verifier that support P256 (secp256r1) keys. + */ +contract ERC7913P256Verifier is IERC7913SignatureVerifier { + /// @inheritdoc IERC7913SignatureVerifier + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) { + // Signature length may be 0x40 or 0x41. + if (key.length == 0x40 && signature.length >= 0x40) { + bytes32 qx = bytes32(key[0x00:0x20]); + bytes32 qy = bytes32(key[0x20:0x40]); + bytes32 r = bytes32(signature[0x00:0x20]); + bytes32 s = bytes32(signature[0x20:0x40]); + if (P256.verify(hash, r, s, qx, qy)) { + return IERC7913SignatureVerifier.verify.selector; + } + } + return 0xFFFFFFFF; + } +} diff --git a/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol b/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol new file mode 100644 index 000000000..1139f9326 --- /dev/null +++ b/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {RSA} from "../../../utils/cryptography/RSA.sol"; +import {IERC7913SignatureVerifier} from "../../../interfaces/IERC7913.sol"; + +/** + * @dev ERC-7913 signature verifier that support RSA keys. + */ +contract ERC7913RSAVerifier is IERC7913SignatureVerifier { + /// @inheritdoc IERC7913SignatureVerifier + function verify(bytes calldata key, bytes32 hash, bytes calldata signature) public view virtual returns (bytes4) { + (bytes memory e, bytes memory n) = abi.decode(key, (bytes, bytes)); + return + RSA.pkcs1Sha256(abi.encodePacked(hash), signature, e, n) + ? IERC7913SignatureVerifier.verify.selector + : bytes4(0xFFFFFFFF); + } +} diff --git a/docs/modules/ROOT/pages/utilities.adoc b/docs/modules/ROOT/pages/utilities.adoc index 91bb320c1..af9cb69bf 100644 --- a/docs/modules/ROOT/pages/utilities.adoc +++ b/docs/modules/ROOT/pages/utilities.adoc @@ -97,6 +97,80 @@ function _verify( IMPORTANT: Always use keys of at least 2048 bits. Additionally, be aware that PKCS#1 v1.5 allows for replayability due to the possibility of arbitrary optional parameters. To prevent replay attacks, consider including an onchain nonce or unique identifier in the message. +=== Signature Verification + +The xref:api:utils.adoc#SignatureChecker[`SignatureChecker`] library provides a unified interface for verifying signatures from different sources. It seamlessly supports: + +* ECDSA signatures from externally owned accounts (EOAs) +* ERC-1271 signatures from smart contract wallets like Argent and Safe Wallet +* ERC-7913 signatures from keys that don't have their own Ethereum address + +This allows developers to write signature verification code once and have it work across all these different signature types. + +==== Basic Signature Verification + +For standard signature verification that supports both EOAs and ERC-1271 contracts: + +[source,solidity] +---- +using SignatureChecker for address; + +function _verifySignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) { + return SignatureChecker.isValidSignatureNow(signer, hash, signature); +} +---- + +The library automatically detects whether the signer is an EOA or a contract and uses the appropriate verification method. + +==== ERC-1271 Contract Signatures + +For smart contract wallets that implement ERC-1271, you can explicitly use: + +[source,solidity] +---- +function _verifyContractSignature(address signer, bytes32 hash, bytes memory signature) internal view returns (bool) { + return SignatureChecker.isValidERC1271SignatureNow(signer, hash, signature); +} +---- + +==== ERC-7913 Extended Signatures + +ERC-7913 extends signature verification to support keys that don't have their own Ethereum address. This is useful for integrating non-Ethereum cryptographic curves, hardware devices, or other identity systems. + +A signer is represented as a `bytes` object that concatenates a verifier address and a key: `verifier || key`. + +[source,solidity] +---- +function _verifyERC7913Signature(bytes memory signer, bytes32 hash, bytes memory signature) internal view returns (bool) { + return SignatureChecker.isValidERC7913SignatureNow(signer, hash, signature); +} +---- + +The verification process works as follows: + +* If `signer.length < 20`: verification fails +* If `signer.length == 20`: verification is done using standard signature checking +* Otherwise: verification is done using an ERC-7913 verifier + +==== Batch Verification + +For verifying multiple ERC-7913 signatures at once: + +[source,solidity] +---- +function _verifyMultipleSignatures( + bytes32 hash, + bytes[] memory signers, + bytes[] memory signatures +) internal view returns (bool) { + return SignatureChecker.areValidERC7913SignaturesNow(hash, signers, signatures); +} +---- + +This function will reject inputs that contain duplicated signers. Sorting the signers by their `keccak256` hash is recommended to minimize the gas cost. + +This unified approach allows smart contracts to accept signatures from any supported source without needing to implement different verification logic for each type. + === Verifying Merkle Proofs Developers can build a Merkle Tree off-chain, which allows for verifying that an element (leaf) is part of a set by using a Merkle Proof. This technique is widely used for creating whitelists (e.g., for airdrops) and other advanced use cases. diff --git a/test/account/AccountERC7913.test.js b/test/account/AccountERC7913.test.js new file mode 100644 index 000000000..118938b17 --- /dev/null +++ b/test/account/AccountERC7913.test.js @@ -0,0 +1,116 @@ +const { ethers, entrypoint } = require('hardhat'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey } = require('../helpers/signers'); +const { PackedUserOperation } = require('../helpers/eip712-types'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); + +// Prepare signer in advance (RSA are long to initialize) +const signerECDSA = ethers.Wallet.createRandom(); +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +// Minimal fixture common to the different signer verifiers +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913P256Verifier'); + const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(entrypoint.v08); + const domain = { name: 'AccountERC7913', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract, + + const makeMock = signer => + helper.newAccount('$AccountERC7913Mock', ['AccountERC7913', '1', signer]).then(mock => { + domain.verifyingContract = mock.address; + return mock; + }); + + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }; + + return { + helper, + verifierP256, + verifierRSA, + domain, + target, + beneficiary, + other, + makeMock, + signUserOp, + }; +} + +describe('AccountERC7913', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + // Using ECDSA key as verifier + describe('ECDSA key', function () { + beforeEach(async function () { + this.signer = signerECDSA; + this.mock = await this.makeMock(this.signer.address); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + // Using P256 key with an ERC-7913 verifier + describe('P256 key', function () { + beforeEach(async function () { + this.signer = signerP256; + this.mock = await this.makeMock( + ethers.concat([ + this.verifierP256.target, + this.signer.signingKey.publicKey.qx, + this.signer.signingKey.publicKey.qy, + ]), + ); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + // Using RSA key with an ERC-7913 verifier + describe('RSA key', function () { + beforeEach(async function () { + this.signer = signerRSA; + this.mock = await this.makeMock( + ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [this.signer.signingKey.publicKey.e, this.signer.signingKey.publicKey.n], + ), + ]), + ); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); +}); diff --git a/test/account/AccountMultiSigner.test.js b/test/account/AccountMultiSigner.test.js new file mode 100644 index 000000000..29badb26b --- /dev/null +++ b/test/account/AccountMultiSigner.test.js @@ -0,0 +1,321 @@ +const { ethers, entrypoint } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const { getDomain } = require('../helpers/eip712'); +const { ERC4337Helper } = require('../helpers/erc4337'); +const { NonNativeSigner, P256SigningKey, RSASHA256SigningKey, MultiERC7913SigningKey } = require('../helpers/signers'); +const { MAX_UINT64 } = require('../helpers/constants'); + +const { shouldBehaveLikeAccountCore, shouldBehaveLikeAccountHolder } = require('./Account.behavior'); +const { shouldBehaveLikeERC1271 } = require('../utils/cryptography/ERC1271.behavior'); +const { shouldBehaveLikeERC7821 } = require('./extensions/ERC7821.behavior'); +const { PackedUserOperation } = require('../helpers/eip712-types'); + +// Prepare signers in advance (RSA are long to initialize) +const signerECDSA1 = ethers.Wallet.createRandom(); +const signerECDSA2 = ethers.Wallet.createRandom(); +const signerECDSA3 = ethers.Wallet.createRandom(); +const signerECDSA4 = ethers.Wallet.createRandom(); // Unauthorized signer +const signerP256 = new NonNativeSigner(P256SigningKey.random()); +const signerRSA = new NonNativeSigner(RSASHA256SigningKey.random()); + +// Minimal fixture common to the different signer verifiers +async function fixture() { + // EOAs and environment + const [beneficiary, other] = await ethers.getSigners(); + const target = await ethers.deployContract('CallReceiverMock'); + + // ERC-7913 verifiers + const verifierP256 = await ethers.deployContract('ERC7913P256Verifier'); + const verifierRSA = await ethers.deployContract('ERC7913RSAVerifier'); + + // ERC-4337 env + const helper = new ERC4337Helper(); + await helper.wait(); + const entrypointDomain = await getDomain(entrypoint.v08); + const domain = { name: 'AccountMultiSigner', version: '1', chainId: entrypointDomain.chainId }; // Missing verifyingContract + + const makeMock = (signers, threshold) => + helper.newAccount('$AccountMultiSignerMock', ['AccountMultiSigner', '1', signers, threshold]).then(mock => { + domain.verifyingContract = mock.address; + return mock; + }); + + // Sign user operations using MultiERC7913SigningKey + const signUserOp = function (userOp) { + return this.signer + .signTypedData(entrypointDomain, { PackedUserOperation }, userOp.packed) + .then(signature => Object.assign(userOp, { signature })); + }; + + const invalidSig = function () { + return this.signer.signMessage('invalid'); + }; + + return { + helper, + verifierP256, + verifierRSA, + domain, + target, + beneficiary, + other, + makeMock, + signUserOp, + invalidSig, + }; +} + +describe('AccountMultiSigner', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('Multi ECDSA signers with threshold=1', function () { + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1])); + this.mock = await this.makeMock([signerECDSA1.address], 1); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Multi ECDSA signers with threshold=2', function () { + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 2); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Mixed signers with threshold=2', function () { + beforeEach(async function () { + // Create signers array with all three types + signerP256.bytes = ethers.concat([ + this.verifierP256.target, + signerP256.signingKey.publicKey.qx, + signerP256.signingKey.publicKey.qy, + ]); + + signerRSA.bytes = ethers.concat([ + this.verifierRSA.target, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes'], + [signerRSA.signingKey.publicKey.e, signerRSA.signingKey.publicKey.n], + ), + ]); + + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerP256, signerRSA])); + this.mock = await this.makeMock([signerECDSA1.address, signerP256.bytes, signerRSA.bytes], 2); + }); + + shouldBehaveLikeAccountCore(); + shouldBehaveLikeAccountHolder(); + shouldBehaveLikeERC1271({ erc7739: true }); + shouldBehaveLikeERC7821(); + }); + + describe('Signer management', function () { + beforeEach(async function () { + this.signer = new NonNativeSigner(new MultiERC7913SigningKey([signerECDSA1, signerECDSA2])); + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1); + await this.mock.deploy(); + }); + + it('can add signers', async function () { + const signers = [signerECDSA3.address]; + + // Successfully adds a signer + const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)); + await expect(this.mock.$_addSigners(signers)) + .to.emit(this.mock, 'ERC7913SignerAdded') + .withArgs(signerECDSA3.address); + const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)); + expect(signersArrayAfter.length).to.equal(signersArrayBefore.length + 1); + expect(signersArrayAfter).to.include(ethers.getAddress(signerECDSA3.address)); + + // Reverts if the signer was already added + await expect(this.mock.$_addSigners(signers)) + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913AlreadyExists') + .withArgs(...signers.map(s => s.toLowerCase())); + }); + + it('can remove signers', async function () { + const signers = [signerECDSA2.address]; + + // Successfully removes an already added signer + const signersArrayBefore = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)); + await expect(this.mock.$_removeSigners(signers)) + .to.emit(this.mock, 'ERC7913SignerRemoved') + .withArgs(signerECDSA2.address); + const signersArrayAfter = await this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)); + expect(signersArrayAfter.length).to.equal(signersArrayBefore.length - 1); + expect(signersArrayAfter).to.not.include(ethers.getAddress(signerECDSA2.address)); + + // Reverts removing a signer if it doesn't exist + await expect(this.mock.$_removeSigners(signers)) + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913NonexistentSigner') + .withArgs(...signers.map(s => s.toLowerCase())); + + // Reverts if removing a signer makes the threshold unreachable + await expect(this.mock.$_removeSigners([signerECDSA1.address])) + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913UnreachableThreshold') + .withArgs(0, 1); + }); + + it('can change threshold', async function () { + // Reachable threshold is set + await expect(this.mock.$_setThreshold(2)).to.emit(this.mock, 'ERC7913ThresholdSet'); + + // Unreachable threshold reverts + await expect(this.mock.$_setThreshold(3)).to.revertedWithCustomError( + this.mock, + 'MultiSignerERC7913UnreachableThreshold', + ); + }); + + it('rejects invalid signer format', async function () { + const invalidSigner = '0x123456'; // Too short + + await expect(this.mock.$_addSigners([invalidSigner])) + .to.be.revertedWithCustomError(this.mock, 'MultiSignerERC7913InvalidSigner') + .withArgs(invalidSigner); + }); + + it('can read signers and threshold', async function () { + await expect( + this.mock.getSigners(0, MAX_UINT64).then(s => s.map(ethers.getAddress)), + ).to.eventually.have.deep.members([signerECDSA1.address, signerECDSA2.address]); + + await expect(this.mock.threshold()).to.eventually.equal(1); + }); + + it('checks if an address is a signer', async function () { + // Should return true for authorized signers + await expect(this.mock.isSigner(signerECDSA1.address)).to.eventually.be.true; + await expect(this.mock.isSigner(signerECDSA2.address)).to.eventually.be.true; + + // Should return false for unauthorized signers + await expect(this.mock.isSigner(signerECDSA3.address)).to.eventually.be.false; + await expect(this.mock.isSigner(signerECDSA4.address)).to.eventually.be.false; + }); + }); + + describe('Signature validation', function () { + const TEST_MESSAGE = ethers.keccak256(ethers.toUtf8Bytes('Test message')); + + beforeEach(async function () { + // Set up mock with authorized signers + this.mock = await this.makeMock([signerECDSA1.address, signerECDSA2.address], 1); + await this.mock.deploy(); + }); + + it('rejects signatures from unauthorized signers', async function () { + // Create signatures including an unauthorized signer + const authorizedSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + const unauthorizedSignature = await signerECDSA4.signMessage(ethers.getBytes(TEST_MESSAGE)); + + // Prepare signers and signatures arrays + const signers = [ + signerECDSA1.address, + signerECDSA4.address, // Unauthorized signer + ].sort((a, b) => (ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1)); + + const signatures = signers.map(signer => { + if (signer === signerECDSA1.address) return authorizedSignature; + return unauthorizedSignature; + }); + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because one signer is not authorized + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; + }); + + it('rejects invalid signatures from authorized signers', async function () { + // Create a valid signature and an invalid one from authorized signers + const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + const invalidSignature = await signerECDSA2.signMessage(ethers.toUtf8Bytes('Different message')); // Wrong message + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) => + ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1, + ); + + const signatures = signers.map(signer => { + if (signer === signerECDSA1.address) return validSignature; + return invalidSignature; + }); + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because one signature is invalid + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; + }); + + it('rejects signatures from unsorted signers', async function () { + // Create a valid signature and an invalid one from authorized signers + const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + const validSignature2 = await signerECDSA2.signMessage(ethers.getBytes(TEST_MESSAGE)); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address, signerECDSA2.address].sort((a, b) => + ethers.toBigInt(ethers.keccak256(a)) < ethers.toBigInt(ethers.keccak256(b)) ? -1 : 1, + ); + const unsortedSigners = signers.reverse(); + const signatures = unsortedSigners.map(signer => { + if (signer === signerECDSA1.address) return validSignature1; + return validSignature2; + }); + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes[]', 'bytes[]'], + [unsortedSigners, signatures], + ); + + // Should fail because signers are not sorted + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; + }); + + it('rejects signatures when signers.length != signatures.length', async function () { + // Create a valid signature and an invalid one from authorized signers + const validSignature1 = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address, signerECDSA2.address]; + const signatures = [validSignature1]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because signers and signatures arrays have different lengths + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; + }); + + it('rejects duplicated signers', async function () { + // Create a valid signature + const validSignature = await signerECDSA1.signMessage(ethers.getBytes(TEST_MESSAGE)); + + // Prepare signers and signatures arrays + const signers = [signerECDSA1.address, signerECDSA1.address]; + const signatures = [validSignature, validSignature]; + + // Encode the multi-signature + const multiSignature = ethers.AbiCoder.defaultAbiCoder().encode(['bytes[]', 'bytes[]'], [signers, signatures]); + + // Should fail because of duplicated signers + await expect(this.mock.$_rawSignatureValidation(TEST_MESSAGE, multiSignature)).to.eventually.be.false; + }); + }); +}); diff --git a/test/helpers/signers.js b/test/helpers/signers.js index d807d592f..baa8b0666 100644 --- a/test/helpers/signers.js +++ b/test/helpers/signers.js @@ -1,4 +1,5 @@ const { + AbiCoder, AbstractSigner, Signature, TypedDataEncoder, @@ -13,6 +14,7 @@ const { hexlify, sha256, toBeHex, + keccak256, } = require('ethers'); const { secp256r1 } = require('@noble/curves/p256'); const { generateKeyPairSync, privateEncrypt } = require('crypto'); @@ -144,4 +146,39 @@ class RSASHA256SigningKey extends RSASigningKey { } } -module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey }; +class MultiERC7913SigningKey { + // this is a sorted array of objects that contain {signer, weight} + #signers; + + constructor(signers) { + assertArgument( + Array.isArray(signers) && signers.length > 0, + 'signers must be a non-empty array', + 'signers', + signers.length, + ); + + // Sorting is done at construction so that it doesn't have to be done in sign() + this.#signers = signers.sort((s1, s2) => keccak256(s1.bytes ?? s1.address) - keccak256(s2.bytes ?? s2.address)); + } + + get signers() { + return this.#signers; + } + + sign(digest /*: BytesLike*/ /*: Signature*/) { + assertArgument(dataLength(digest) === 32, 'invalid digest length', 'digest', digest); + + return { + serialized: AbiCoder.defaultAbiCoder().encode( + ['bytes[]', 'bytes[]'], + [ + this.#signers.map(signer => signer.bytes ?? signer.address), + this.#signers.map(signer => signer.signingKey.sign(digest).serialized), + ], + ), + }; + } +} + +module.exports = { NonNativeSigner, P256SigningKey, RSASigningKey, RSASHA256SigningKey, MultiERC7913SigningKey }; diff --git a/test/utils/cryptography/SignatureChecker.test.js b/test/utils/cryptography/SignatureChecker.test.js index 2b14f2f4c..e767adfa8 100644 --- a/test/utils/cryptography/SignatureChecker.test.js +++ b/test/utils/cryptography/SignatureChecker.test.js @@ -3,6 +3,7 @@ const { expect } = require('chai'); const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); const precompile = require('../../helpers/precompiles'); +const { P256SigningKey, NonNativeSigner } = require('../../helpers/signers'); const TEST_MESSAGE = ethers.id('OpenZeppelin'); const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); @@ -10,14 +11,19 @@ const TEST_MESSAGE_HASH = ethers.hashMessage(TEST_MESSAGE); const WRONG_MESSAGE = ethers.id('Nope'); const WRONG_MESSAGE_HASH = ethers.hashMessage(WRONG_MESSAGE); +const aliceP256 = new NonNativeSigner(P256SigningKey.random()); +const bobP256 = new NonNativeSigner(P256SigningKey.random()); + async function fixture() { - const [signer, other] = await ethers.getSigners(); + const [signer, extraSigner, other] = await ethers.getSigners(); const mock = await ethers.deployContract('$SignatureChecker'); const wallet = await ethers.deployContract('ERC1271WalletMock', [signer]); + const wallet2 = await ethers.deployContract('ERC1271WalletMock', [extraSigner]); const malicious = await ethers.deployContract('ERC1271MaliciousMock'); const signature = await signer.signMessage(TEST_MESSAGE); + const verifier = await ethers.deployContract('ERC7913P256Verifier'); - return { signer, other, mock, wallet, malicious, signature }; + return { signer, other, extraSigner, mock, wallet, wallet2, malicious, signature, verifier }; } describe('SignatureChecker (ERC1271)', function () { @@ -72,4 +78,316 @@ describe('SignatureChecker (ERC1271)', function () { }); } }); + + describe('ERC7913', function () { + describe('isValidERC7913SignatureNow', function () { + describe('with EOA signer', function () { + it('with matching signer and signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, TEST_MESSAGE_HASH, signature)).to.eventually.be + .true; + }); + + it('with invalid signer', async function () { + const eoaSigner = ethers.zeroPadValue(this.other.address, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, TEST_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + + it('with invalid signature', async function () { + const eoaSigner = ethers.zeroPadValue(this.signer.address, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(eoaSigner, WRONG_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + }); + + describe('with ERC-1271 wallet', function () { + it('with matching signer and signature', async function () { + const walletSigner = ethers.zeroPadValue(this.wallet.target, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, TEST_MESSAGE_HASH, signature)).to.eventually + .be.true; + }); + + it('with invalid signer', async function () { + const walletSigner = ethers.zeroPadValue(this.mock.target, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, TEST_MESSAGE_HASH, signature)).to.eventually + .be.false; + }); + + it('with invalid signature', async function () { + const walletSigner = ethers.zeroPadValue(this.wallet.target, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(walletSigner, WRONG_MESSAGE_HASH, signature)).to.eventually + .be.false; + }); + }); + + describe('with ERC-7913 verifier', function () { + it('with matching signer and signature', async function () { + const signer = ethers.concat([ + this.verifier.target, + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]); + const signature = await aliceP256.signMessage(TEST_MESSAGE); + + await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be + .true; + }); + + it('with invalid verifier', async function () { + const signer = ethers.concat([ + this.mock.target, // invalid verifier + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]); + const signature = await aliceP256.signMessage(TEST_MESSAGE); + + await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + + it('with invalid key', async function () { + const signer = ethers.concat([this.verifier.target, ethers.randomBytes(32)]); + const signature = await aliceP256.signMessage(TEST_MESSAGE); + + await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + + it('with invalid signature', async function () { + const signer = ethers.concat([ + this.verifier.target, + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]); + const signature = ethers.randomBytes(65); // invalid (random) signature + + await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + + it('with signer too short', async function () { + const signer = ethers.randomBytes(19); // too short + const signature = await aliceP256.signMessage(TEST_MESSAGE); + await expect(this.mock.$isValidERC7913SignatureNow(signer, TEST_MESSAGE_HASH, signature)).to.eventually.be + .false; + }); + }); + }); + + describe('areValidERC7913SignaturesNow', function () { + const sortSigners = (...signers) => + signers.sort(({ signer: a }, { signer: b }) => ethers.keccak256(b) - ethers.keccak256(a)); + + it('should validate a single signature', async function () { + const signer = ethers.zeroPadValue(this.signer.address, 20); + const signature = await this.signer.signMessage(TEST_MESSAGE); + + await expect(this.mock.$areValidERC7913SignaturesNow(TEST_MESSAGE_HASH, [signer], [signature])).to.eventually.be + .true; + }); + + it('should validate multiple signatures with different signer types', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.wallet.target, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.concat([ + this.verifier.target, + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]), + signature: await aliceP256.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.true; + }); + + it('should validate multiple EOA signatures', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.extraSigner.address, 20), + signature: await this.extraSigner.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.true; + }); + + it('should validate multiple ERC-1271 wallet signatures', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.wallet.target, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.wallet2.target, 20), + signature: await this.extraSigner.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.true; + }); + + it('should validate multiple ERC-7913 signatures (ordered by ID)', async function () { + const signers = sortSigners( + { + signer: ethers.concat([ + this.verifier.target, + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]), + signature: await aliceP256.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.concat([ + this.verifier.target, + bobP256.signingKey.publicKey.qx, + bobP256.signingKey.publicKey.qy, + ]), + signature: await bobP256.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.true; + }); + + it('should validate multiple ERC-7913 signatures (unordered)', async function () { + const signers = sortSigners( + { + signer: ethers.concat([ + this.verifier.target, + aliceP256.signingKey.publicKey.qx, + aliceP256.signingKey.publicKey.qy, + ]), + signature: await aliceP256.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.concat([ + this.verifier.target, + bobP256.signingKey.publicKey.qx, + bobP256.signingKey.publicKey.qy, + ]), + signature: await bobP256.signMessage(TEST_MESSAGE), + }, + ).reverse(); // reverse + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.true; + }); + + it('should return false if any signature is invalid', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.extraSigner.address, 20), + signature: await this.extraSigner.signMessage(WRONG_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.false; + }); + + it('should return false if there are duplicate signers', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature), + ), + ).to.eventually.be.false; + }); + + it('should return false if signatures array length does not match signers array length', async function () { + const signers = sortSigners( + { + signer: ethers.zeroPadValue(this.signer.address, 20), + signature: await this.signer.signMessage(TEST_MESSAGE), + }, + { + signer: ethers.zeroPadValue(this.extraSigner.address, 20), + signature: await this.extraSigner.signMessage(TEST_MESSAGE), + }, + ); + + await expect( + this.mock.$areValidERC7913SignaturesNow( + TEST_MESSAGE_HASH, + signers.map(({ signer }) => signer), + signers.map(({ signature }) => signature).slice(1), + ), + ).to.eventually.be.false; + }); + + it('should pass with empty arrays', async function () { + await expect(this.mock.$areValidERC7913SignaturesNow(TEST_MESSAGE_HASH, [], [])).to.eventually.be.true; + }); + }); + }); });