Skip to content

Commit 825f6e4

Browse files
committed
feat: use replay-safe hashes in SSV
1 parent 8d573c5 commit 825f6e4

File tree

5 files changed

+79
-9
lines changed

5 files changed

+79
-9
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
@@ -8,6 +8,8 @@ import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/Signa
88
import {IModule, ModuleMetadata} from "../../interfaces/IModule.sol";
99
import {IValidationModule} from "../../interfaces/IValidationModule.sol";
1010
import {BaseModule} from "../BaseModule.sol";
11+
12+
import {ReplaySafeWrapper} from "../ReplaySafeWrapper.sol";
1113
import {ISingleSignerValidationModule} from "./ISingleSignerValidationModule.sol";
1214

1315
/// @title ECSDA Validation
@@ -18,12 +20,11 @@ import {ISingleSignerValidationModule} from "./ISingleSignerValidationModule.sol
1820
/// the account. Account states are to be retrieved from this global singleton directly.
1921
///
2022
/// - This validation supports ERC-1271. The signature is valid if it is signed by the owner's private key
21-
/// (if the owner is an EOA) or if it is a valid ERC-1271 signature from the
22-
/// owner (if the owner is a contract).
23+
/// (if the owner is an EOA) or if it is a valid ERC-1271 signature from the owner (if the owner is a contract).
2324
///
2425
/// - This validation supports composition that other validation can relay on entities in this validation
2526
/// to validate partially or fully.
26-
contract SingleSignerValidationModule is ISingleSignerValidationModule, BaseModule {
27+
contract SingleSignerValidationModule is ISingleSignerValidationModule, ReplaySafeWrapper, BaseModule {
2728
using MessageHashUtils for bytes32;
2829

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

test/account/UpgradeableModularAccount.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,9 +377,9 @@ contract UpgradeableModularAccountTest is AccountTestBase {
377377
function test_isValidSignature() public {
378378
bytes32 message = keccak256("hello world");
379379

380-
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, message);
380+
bytes32 replaySafeHash = singleSignerValidationModule.replaySafeHash(address(account1), message);
381381

382-
// singleSignerValidationModule.ownerOf(address(account1));
382+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(owner1Key, replaySafeHash);
383383

384384
bytes memory signature =
385385
abi.encodePacked(address(singleSignerValidationModule), TEST_DEFAULT_VALIDATION_ENTITY_ID, r, s, v);

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)