Skip to content

Commit bd33993

Browse files
authored
feat(SingleSignerValidation): add replay safe hash and utility contracts for EIP-712 in modules [2/2] (#141)
1 parent 0c18f92 commit bd33993

File tree

6 files changed

+89
-13
lines changed

6 files changed

+89
-13
lines changed

src/modules/ModuleEIP712.sol

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.25;
3+
4+
// A base for modules that use EIP-712 structured data signing.
5+
//
6+
// Unlike other EIP712 libraries, this mixin uses the salt field to hold the account address.
7+
//
8+
// It omits the name and version from the EIP-712 domain, as modules are intended to be deployed as
9+
// immutable singletons, thus a different versions and instances would have a different module address.
10+
//
11+
// Due to depending on the account address to calculate the domain separator, this abstract contract does not
12+
// implement EIP-5267, as the domain retrieval function does not provide a parameter to use for the account address
13+
// (internally the verifyingContract), and the computed `msg.sender` for an `eth_call` without an override is
14+
// address(0).
15+
abstract contract ModuleEIP712 {
16+
// keccak256(
17+
// "EIP712Domain(uint256 chainId,address verifyingContract,bytes32 salt)"
18+
// )
19+
bytes32 private constant _DOMAIN_SEPARATOR_TYPEHASH =
20+
0x71062c282d40422f744945d587dbf4ecfd4f9cfad1d35d62c944373009d96162;
21+
22+
function _domainSeparator(address account) internal view returns (bytes32) {
23+
return keccak256(
24+
abi.encode(
25+
_DOMAIN_SEPARATOR_TYPEHASH, block.chainid, address(this), bytes32(uint256(uint160(account)))
26+
)
27+
);
28+
}
29+
}

src/modules/ReplaySafeWrapper.sol

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity ^0.8.25;
3+
4+
import {ModuleEIP712} from "./ModuleEIP712.sol";
5+
6+
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
7+
8+
// A contract mixin for modules that wish to use EIP-712 to wrap the hashes sent to the EIP-1271 function
9+
// `isValidSignature`.
10+
// This makes signatures generated by owners of contract accounts non-replayable across multiple accounts owned by
11+
// the same owner.
12+
abstract contract ReplaySafeWrapper is ModuleEIP712 {
13+
// keccak256("ReplaySafeHash(bytes32 hash)")
14+
bytes32 private constant _REPLAY_SAFE_HASH_TYPEHASH =
15+
0x294a8735843d4afb4f017c76faf3b7731def145ed0025fc9b1d5ce30adf113ff;
16+
17+
/// @notice Wraps a hash in an EIP-712 envelope to prevent cross-account replay attacks.
18+
/// Uses the ModuleEIP712 domain separator, which includes the chainId, module address, and account address.
19+
/// @param account The account that will validate the message hash.
20+
/// @param hash The hash to wrap.
21+
/// @return The the replay-safe hash, computed by wrapping the input hash in an EIP-712 struct.
22+
function replaySafeHash(address account, bytes32 hash) public view virtual returns (bytes32) {
23+
return MessageHashUtils.toTypedDataHash({
24+
domainSeparator: _domainSeparator(account),
25+
structHash: _hashStruct(hash)
26+
});
27+
}
28+
29+
function _hashStruct(bytes32 hash) internal view virtual returns (bytes32) {
30+
return keccak256(abi.encode(_REPLAY_SAFE_HASH_TYPEHASH, hash));
31+
}
32+
}

src/modules/validation/SingleSignerValidationModule.sol

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pragma solidity ^0.8.25;
44
import {IModule, ModuleMetadata} from "../../interfaces/IModule.sol";
55
import {IValidationModule} from "../../interfaces/IValidationModule.sol";
66
import {BaseModule} from "../BaseModule.sol";
7+
8+
import {ReplaySafeWrapper} from "../ReplaySafeWrapper.sol";
79
import {ISingleSignerValidationModule} from "./ISingleSignerValidationModule.sol";
810
import {PackedUserOperation} from "@eth-infinitism/account-abstraction/interfaces/PackedUserOperation.sol";
911
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
@@ -17,12 +19,11 @@ import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/Signa
1719
/// the account. Account states are to be retrieved from this global singleton directly.
1820
///
1921
/// - This validation supports ERC-1271. The signature is valid if it is signed by the owner's private key
20-
/// (if the owner is an EOA) or if it is a valid ERC-1271 signature from the
21-
/// owner (if the owner is a contract).
22+
/// (if the owner is an EOA) or if it is a valid ERC-1271 signature from the owner (if the owner is a contract).
2223
///
2324
/// - This validation supports composition that other validation can relay on entities in this validation
2425
/// to validate partially or fully.
25-
contract SingleSignerValidationModule is ISingleSignerValidationModule, BaseModule {
26+
contract SingleSignerValidationModule is ISingleSignerValidationModule, ReplaySafeWrapper, BaseModule {
2627
using MessageHashUtils for bytes32;
2728

2829
string internal constant _NAME = "SingleSigner Validation";
@@ -93,14 +94,17 @@ contract SingleSignerValidationModule is ISingleSignerValidationModule, BaseModu
9394
/// @inheritdoc IValidationModule
9495
/// @dev The signature is valid if it is signed by the owner's private key
9596
/// (if the owner is an EOA) or if it is a valid ERC-1271 signature from the
96-
/// owner (if the owner is a contract). Note that the signature is wrapped in an EIP-191 message
97+
/// owner (if the owner is a contract).
98+
/// Note that the digest is wrapped in an EIP-712 struct to prevent cross-account replay attacks. The
99+
/// replay-safe hash may be retrieved by calling the public function `replaySafeHash`.
97100
function validateSignature(address account, uint32 entityId, address, bytes32 digest, bytes calldata signature)
98101
external
99102
view
100103
override
101104
returns (bytes4)
102105
{
103-
if (SignatureChecker.isValidSignatureNow(signers[entityId][account], digest, signature)) {
106+
bytes32 _replaySafeHash = replaySafeHash(account, digest);
107+
if (SignatureChecker.isValidSignatureNow(signers[entityId][account], _replaySafeHash, signature)) {
104108
return _1271_MAGIC_VALUE;
105109
}
106110
return _1271_INVALID;

test/account/PerHookData.t.sol

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@ contract PerHookDataTest is CustomValidationTestBase {
2323
Counter internal _counter;
2424

2525
function setUp() public {
26-
_signerValidation =
27-
ModuleEntityLib.pack(address(singleSignerValidationModule), TEST_DEFAULT_VALIDATION_ENTITY_ID);
28-
2926
_counter = new Counter();
3027

3128
_accessControlHookModule = new MockAccessControlHookModule();
@@ -360,7 +357,8 @@ contract PerHookDataTest is CustomValidationTestBase {
360357

361358
bytes32 messageHash = keccak256(abi.encodePacked(message));
362359

363-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, messageHash);
360+
bytes32 replaySafeHash = singleSignerValidationModule.replaySafeHash(address(account1), messageHash);
361+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, replaySafeHash);
364362

365363
PreValidationHookData[] memory preValidationHookData = new PreValidationHookData[](1);
366364
preValidationHookData[0] = PreValidationHookData({index: 0, validationData: abi.encodePacked(message)});

test/account/UpgradeableModularAccount.t.sol

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,17 @@ contract UpgradeableModularAccountTest is AccountTestBase {
392392
function test_isValidSignature() public {
393393
bytes32 message = keccak256("hello world");
394394

395-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, message);
395+
uint8 v;
396+
bytes32 r;
397+
bytes32 s;
396398

397-
// singleSignerValidationModule.ownerOf(address(account1));
399+
if (vm.envOr("SMA_TEST", false)) {
400+
// todo: implement replay-safe hashing for SMA
401+
(v, r, s) = vm.sign(owner1Key, message);
402+
} else {
403+
bytes32 replaySafeHash = singleSignerValidationModule.replaySafeHash(address(account1), message);
404+
(v, r, s) = vm.sign(owner1Key, replaySafeHash);
405+
}
398406

399407
bytes memory signature = _encode1271Signature(_signerValidation, abi.encodePacked(r, s, v));
400408

test/module/SingleSignerValidationModule.t.sol

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,10 @@ contract SingleSignerValidationModuleTest is AccountTestBase {
113113

114114
address accountAddr = address(account);
115115

116+
bytes32 replaySafeHash = singleSignerValidationModule.replaySafeHash(accountAddr, digest);
117+
116118
vm.startPrank(accountAddr);
117-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest);
119+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, replaySafeHash);
118120

119121
// sig check should fail
120122
assertEq(
@@ -141,7 +143,10 @@ contract SingleSignerValidationModuleTest is AccountTestBase {
141143
address accountAddr = address(account);
142144
vm.startPrank(accountAddr);
143145
singleSignerValidationModule.transferSigner(TEST_DEFAULT_VALIDATION_ENTITY_ID, address(contractOwner));
144-
bytes memory signature = contractOwner.sign(digest);
146+
147+
bytes32 replaySafeHash = singleSignerValidationModule.replaySafeHash(accountAddr, digest);
148+
149+
bytes memory signature = contractOwner.sign(replaySafeHash);
145150
assertEq(
146151
singleSignerValidationModule.validateSignature(
147152
accountAddr, TEST_DEFAULT_VALIDATION_ENTITY_ID, address(this), digest, signature

0 commit comments

Comments
 (0)