Skip to content

Commit 6af630f

Browse files
committed
test: add failing test for EOA admin key validation issue
This test demonstrates that when an EOA's own address is used as the publicKey for an admin key (which happens when using the EOA's private key to create the admin key), the validation fails due to a recursive validation loop in the signature checking logic. The issue occurs because: 1. unwrapAndValidateSignature extracts the inner signature 2. For Secp256k1 keys, it calls isValidSignatureNowCalldata with the EOA address 3. Since the EOA has code (via EIP-7702), it calls isValidSignature on the EOA 4. This triggers the 64/65 byte special case expecting raw EOA signature 5. But the signature is EIP-712 formatted, causing validation to fail
1 parent e84fe99 commit 6af630f

File tree

1 file changed

+151
-0
lines changed

1 file changed

+151
-0
lines changed

test/EOAKeyConflict.t.sol

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.4;
3+
4+
import "./Base.t.sol";
5+
6+
/// @dev Test that reproduces the issue where using the EOA's private key as an admin key
7+
/// causes validation failure due to recursive validation loop.
8+
contract EOAKeyConflictTest is BaseTest {
9+
10+
/// @dev Test that using EOA's own address as admin key publicKey causes validation failure
11+
function testEOAAsAdminKeyFails() public {
12+
// Create a delegated EOA
13+
DelegatedEOA memory d = _randomEIP7702DelegatedEOA();
14+
vm.deal(d.eoa, 100 ether);
15+
16+
// Create an admin key where the publicKey is the EOA's own address
17+
// This simulates what happens when using mock_admin_with_key(KeyType::Secp256k1, eoa_private_key)
18+
PassKey memory adminKey;
19+
adminKey.k.keyType = IthacaAccount.KeyType.Secp256k1;
20+
adminKey.k.publicKey = abi.encode(d.eoa); // EOA's address as publicKey
21+
adminKey.k.isSuperAdmin = true;
22+
adminKey.k.expiry = 0;
23+
adminKey.privateKey = d.privateKey; // Same private key as EOA
24+
adminKey.keyHash = _hash(adminKey.k);
25+
26+
// Authorize the key
27+
vm.prank(d.eoa);
28+
d.d.authorize(adminKey.k);
29+
30+
// Create a simple call
31+
ERC7821.Call[] memory calls = new ERC7821.Call[](1);
32+
calls[0] = _thisTargetFunctionCall(0, hex"");
33+
34+
// Get nonce and compute digest
35+
uint256 nonce = d.d.getNonce(0);
36+
bytes32 digest = d.d.computeDigest(calls, nonce);
37+
38+
// Sign with the admin key (using EOA's private key)
39+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(adminKey.privateKey, digest);
40+
bytes memory innerSignature = abi.encodePacked(r, s, v);
41+
42+
// Wrap the signature with keyHash and prehash flag (as relay.rs does)
43+
bytes memory wrappedSignature = abi.encodePacked(
44+
innerSignature,
45+
adminKey.keyHash,
46+
uint8(0) // prehash = false
47+
);
48+
49+
// Try to execute with the wrapped signature
50+
bytes memory opData = abi.encodePacked(nonce, wrappedSignature);
51+
bytes memory executionData = abi.encode(calls, opData);
52+
53+
// This should fail because:
54+
// 1. unwrapAndValidateSignature extracts the 65-byte inner signature
55+
// 2. For Secp256k1 keys, it calls SignatureCheckerLib.isValidSignatureNowCalldata
56+
// 3. Since publicKey is the EOA's address and EOA has code (delegated),
57+
// it calls isValidSignature on the EOA
58+
// 4. This triggers the 64/65 byte special case which expects ecrecover to return EOA address
59+
// 5. But ecrecover returns a different address because the digest is EIP-712 formatted
60+
vm.expectRevert(bytes4(keccak256("Unauthorized()")));
61+
d.d.execute(_ERC7821_BATCH_EXECUTION_MODE, executionData);
62+
}
63+
64+
/// @dev Test that using a different key works correctly
65+
function testDifferentAdminKeyWorks() public {
66+
// Create a delegated EOA
67+
DelegatedEOA memory d = _randomEIP7702DelegatedEOA();
68+
vm.deal(d.eoa, 100 ether);
69+
70+
// Create an admin key with a DIFFERENT address/private key
71+
PassKey memory adminKey = _randomSecp256k1PassKey();
72+
adminKey.k.isSuperAdmin = true;
73+
74+
// Authorize the key
75+
vm.prank(d.eoa);
76+
d.d.authorize(adminKey.k);
77+
78+
// Create a simple call
79+
ERC7821.Call[] memory calls = new ERC7821.Call[](1);
80+
calls[0] = _thisTargetFunctionCall(0, hex"");
81+
82+
// Get nonce and compute digest
83+
uint256 nonce = d.d.getNonce(0);
84+
bytes32 digest = d.d.computeDigest(calls, nonce);
85+
86+
// Sign with the admin key
87+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(adminKey.privateKey, digest);
88+
bytes memory innerSignature = abi.encodePacked(r, s, v);
89+
90+
// Wrap the signature with keyHash and prehash flag
91+
bytes memory wrappedSignature = abi.encodePacked(
92+
innerSignature,
93+
adminKey.keyHash,
94+
uint8(0) // prehash = false
95+
);
96+
97+
// Execute with the wrapped signature
98+
bytes memory opData = abi.encodePacked(nonce, wrappedSignature);
99+
bytes memory executionData = abi.encode(calls, opData);
100+
101+
// This should succeed because the admin key's publicKey is NOT the EOA's address
102+
d.d.execute(_ERC7821_BATCH_EXECUTION_MODE, executionData);
103+
104+
// Verify the call was executed
105+
assertEq(targetFunctionPayloads.length, 1);
106+
assertEq(targetFunctionPayloads[0].by, d.eoa);
107+
}
108+
109+
/// @dev Test that demonstrates the exact validation flow when EOA is used as admin key
110+
function testValidationFlowWithEOAKey() public {
111+
// Create a delegated EOA
112+
DelegatedEOA memory d = _randomEIP7702DelegatedEOA();
113+
114+
// Create an admin key where publicKey is EOA's address
115+
PassKey memory adminKey;
116+
adminKey.k.keyType = IthacaAccount.KeyType.Secp256k1;
117+
adminKey.k.publicKey = abi.encode(d.eoa);
118+
adminKey.k.isSuperAdmin = true;
119+
adminKey.privateKey = d.privateKey;
120+
adminKey.keyHash = _hash(adminKey.k);
121+
122+
// Authorize the key
123+
vm.prank(d.eoa);
124+
d.d.authorize(adminKey.k);
125+
126+
// Create a digest for testing
127+
bytes32 digest = keccak256("test message");
128+
129+
// Sign with the admin key
130+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(adminKey.privateKey, digest);
131+
bytes memory innerSignature = abi.encodePacked(r, s, v);
132+
133+
// Test unwrapAndValidateSignature directly with wrapped signature
134+
bytes memory wrappedSignature = abi.encodePacked(
135+
innerSignature,
136+
adminKey.keyHash,
137+
uint8(0)
138+
);
139+
140+
// This will fail because:
141+
// 1. It unwraps to get 65-byte innerSignature
142+
// 2. For Secp256k1, calls isValidSignatureNowCalldata(d.eoa, digest, innerSignature)
143+
// 3. Since d.eoa has code, it calls isValidSignature on d.eoa
144+
// 4. That hits the 65-byte special case, tries ecrecover
145+
// 5. ecrecover doesn't return d.eoa because the signature is for a different digest context
146+
(bool isValid, bytes32 returnedKeyHash) = d.d.unwrapAndValidateSignature(digest, wrappedSignature);
147+
148+
assertFalse(isValid, "Validation should fail when EOA is used as admin key");
149+
assertEq(returnedKeyHash, adminKey.keyHash, "Should return the correct keyHash even on failure");
150+
}
151+
}

0 commit comments

Comments
 (0)