Add ERC7913 signers and utilities (#5659)
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
This commit is contained in:
5
.changeset/nice-rings-wish.md
Normal file
5
.changeset/nice-rings-wish.md
Normal file
@ -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.
|
||||
5
.changeset/quiet-kiwis-feel.md
Normal file
5
.changeset/quiet-kiwis-feel.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'openzeppelin-solidity': minor
|
||||
---
|
||||
|
||||
`SignerERC7913`: Abstract signer that verifies signatures using the ERC-7913 workflow.
|
||||
5
.changeset/social-walls-obey.md
Normal file
5
.changeset/social-walls-obey.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'openzeppelin-solidity': minor
|
||||
---
|
||||
|
||||
`MultiSignerERC7913`: Implementation of `AbstractSigner` that supports multiple ERC-7913 signers with a threshold-based signature verification system.
|
||||
5
.changeset/sour-pens-shake.md
Normal file
5
.changeset/sour-pens-shake.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
'openzeppelin-solidity': minor
|
||||
---
|
||||
|
||||
`SignatureChecker`: Add support for ERC-7913 signatures alongside existing ECDSA and ERC-1271 signature verification.
|
||||
17
contracts/interfaces/IERC7913.sol
Normal file
17
contracts/interfaces/IERC7913.sol
Normal file
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
238
contracts/utils/cryptography/signers/MultiSignerERC7913.sol
Normal file
238
contracts/utils/cryptography/signers/MultiSignerERC7913.sol
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
51
contracts/utils/cryptography/signers/SignerERC7913.sol
Normal file
51
contracts/utils/cryptography/signers/SignerERC7913.sol
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
116
test/account/AccountERC7913.test.js
Normal file
116
test/account/AccountERC7913.test.js
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
321
test/account/AccountMultiSigner.test.js
Normal file
321
test/account/AccountMultiSigner.test.js
Normal file
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user