Skip to content

Commit 8f39d42

Browse files
fix(audit): add salt to Merkle leaf hashing (#1580)
**Motivation:** As part of an audit finding, to protect against [second preimage attacks](https://flawed.net.nz/2018/02/21/attacking-merkle-trees-with-a-second-preimage-attack/), we add a salt to the leaf similar to the RewardsCoordinator to significantly reduce the likelihood of an internal node being used to produce an unintentional proof. **Modifications:** * Created new `LeafCalculatorMixin` with `getOperatorInfoLeaf` and `getOperatorTableLeaf` calculations, which take in salt * Updated tests to use `getOperatorInfoLeaf` and `getOperatorTableLeaf` for hash calculation **Result:** Significantly diminished likelihood of second preimage attack --------- Co-authored-by: Yash Patil <[email protected]>
1 parent 27b27a1 commit 8f39d42

File tree

12 files changed

+682
-26
lines changed

12 files changed

+682
-26
lines changed

pkg/bindings/BN254CertificateVerifier/binding.go

Lines changed: 126 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/bindings/LeafCalculatorMixin/binding.go

Lines changed: 317 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/bindings/OperatorTableUpdater/binding.go

Lines changed: 126 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// SPDX-License-Identifier: BUSL-1.1
2+
pragma solidity ^0.8.27;
3+
4+
import {IOperatorTableCalculatorTypes} from "../interfaces/IOperatorTableCalculator.sol";
5+
6+
/**
7+
* @title LeafCalculatorMixin
8+
* @notice Reusable mixin for calculating operator info and operator table leaf hashes
9+
* @dev Provides standardized leaf calculation functions for use across multiple contracts and repositories.
10+
* This mixin centralizes the leaf hashing logic to ensure consistency across the EigenLayer ecosystem
11+
* and maintains proper cryptographic security through salt-based domain separation.
12+
*/
13+
abstract contract LeafCalculatorMixin {
14+
/// @dev Salt for operator info leaf hash calculation
15+
/// @dev The salt is used to prevent against second preimage attacks: attacks where an
16+
/// attacker can create a partial proof using an internal node rather than a leaf to
17+
/// validate a proof. The salt ensures that leaves cannot be concatenated together to
18+
/// form a valid proof, as well as reducing the likelihood of an internal node matching
19+
/// the salt prefix.
20+
/// @dev Value derived from keccak256("OPERATOR_INFO_LEAF_SALT") = 0x38...
21+
/// This ensures collision resistance and semantic meaning.
22+
uint8 public constant OPERATOR_INFO_LEAF_SALT = 0x75;
23+
24+
/// @dev Salt for operator table leaf hash calculation
25+
/// @dev The salt is used to prevent against second preimage attacks: attacks where an
26+
/// attacker can create a partial proof using an internal node rather than a leaf to
27+
/// validate a proof. The salt ensures that leaves cannot be concatenated together to
28+
/// form a valid proof, as well as reducing the likelihood of an internal node matching
29+
/// the salt prefix.
30+
/// @dev Value derived from keccak256("OPERATOR_TABLE_LEAF_SALT") = 0x7d...
31+
/// This ensures collision resistance and semantic meaning.
32+
uint8 public constant OPERATOR_TABLE_LEAF_SALT = 0x8e;
33+
34+
/**
35+
* @notice Calculate the leaf hash for an operator info
36+
* @param operatorInfo The BN254 operator info struct containing the operator's public key and stake weights
37+
* @return The leaf hash (keccak256 of salt and encoded operator info)
38+
* @dev The salt is used to prevent against second preimage attacks: attacks where an
39+
* attacker can create a partial proof using an internal node rather than a leaf to
40+
* validate a proof. The salt ensures that leaves cannot be concatenated together to
41+
* form a valid proof, as well as reducing the likelihood of an internal node matching
42+
* the salt prefix.
43+
*
44+
* This is a standard "domain separation" technique in Merkle tree implementations
45+
* to ensure leaf nodes and internal nodes can never be confused with each other.
46+
* See Section 2.1 of <https://www.rfc-editor.org/rfc/rfc9162#name-merkle-trees> for more.
47+
*
48+
* Uses abi.encodePacked for the salt and abi.encode for the struct to handle complex types
49+
* (structs with dynamic arrays) while maintaining gas efficiency where possible.
50+
*/
51+
function calculateOperatorInfoLeaf(
52+
IOperatorTableCalculatorTypes.BN254OperatorInfo memory operatorInfo
53+
) public pure returns (bytes32) {
54+
return keccak256(abi.encodePacked(OPERATOR_INFO_LEAF_SALT, abi.encode(operatorInfo)));
55+
}
56+
57+
/**
58+
* @notice Calculate the leaf hash for an operator table
59+
* @param operatorTableBytes The encoded operator table as bytes containing operator set data
60+
* @return The leaf hash (keccak256 of salt and operator table bytes)
61+
* @dev The salt is used to prevent against second preimage attacks: attacks where an
62+
* attacker can create a partial proof using an internal node rather than a leaf to
63+
* validate a proof. The salt ensures that leaves cannot be concatenated together to
64+
* form a valid proof, as well as reducing the likelihood of an internal node matching
65+
* the salt prefix.
66+
*
67+
* This is a standard "domain separation" technique in Merkle tree implementations
68+
* to ensure leaf nodes and internal nodes can never be confused with each other.
69+
* See Section 2.1 of <https://www.rfc-editor.org/rfc/rfc9162#name-merkle-trees> for more.
70+
*
71+
* Uses abi.encodePacked for both salt and bytes for optimal gas efficiency since both
72+
* are simple byte arrays without complex nested structures.
73+
*/
74+
function calculateOperatorTableLeaf(
75+
bytes calldata operatorTableBytes
76+
) public pure returns (bytes32) {
77+
return keccak256(abi.encodePacked(OPERATOR_TABLE_LEAF_SALT, operatorTableBytes));
78+
}
79+
}

src/contracts/multichain/BN254CertificateVerifier.sol

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import "../libraries/BN254SignatureVerifier.sol";
88
import "../libraries/Merkle.sol";
99
import "../libraries/OperatorSetLib.sol";
1010
import "../mixins/SemVerMixin.sol";
11+
import "../mixins/LeafCalculatorMixin.sol";
1112
import "./BN254CertificateVerifierStorage.sol";
1213

1314
/**
@@ -16,7 +17,12 @@ import "./BN254CertificateVerifierStorage.sol";
1617
* @dev This contract uses BN254 curves for signature verification and
1718
* caches operator information for efficient verification
1819
*/
19-
contract BN254CertificateVerifier is Initializable, BN254CertificateVerifierStorage, SemVerMixin {
20+
contract BN254CertificateVerifier is
21+
Initializable,
22+
BN254CertificateVerifierStorage,
23+
SemVerMixin,
24+
LeafCalculatorMixin
25+
{
2026
using Merkle for bytes;
2127
using BN254 for BN254.G1Point;
2228

@@ -282,7 +288,7 @@ contract BN254CertificateVerifier is Initializable, BN254CertificateVerifierStor
282288
BN254OperatorInfo memory operatorInfo,
283289
bytes memory proof
284290
) internal view returns (bool verified) {
285-
bytes32 leaf = keccak256(abi.encode(operatorInfo));
291+
bytes32 leaf = calculateOperatorInfoLeaf(operatorInfo);
286292
bytes32 root = _operatorSetInfos[operatorSetKey][referenceTimestamp].operatorInfoTreeRoot;
287293
return proof.verifyInclusionKeccak(root, leaf, operatorIndex);
288294
}

src/contracts/multichain/BN254CertificateVerifierStorage.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ abstract contract BN254CertificateVerifierStorage is IBN254CertificateVerifier {
1515
/// @dev Basis point unit denominator for division
1616
uint256 internal constant BPS_DENOMINATOR = 10_000;
1717

18+
// OPERATOR_INFO_LEAF_SALT is now inherited from LeafCalculatorMixin
19+
1820
// Immutables
1921

2022
/// @dev The address that can update operator tables

src/contracts/multichain/OperatorTableUpdater.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import "@openzeppelin-upgrades/contracts/security/ReentrancyGuardUpgradeable.sol
88
import "../libraries/Merkle.sol";
99
import "../permissions/Pausable.sol";
1010
import "../mixins/SemVerMixin.sol";
11+
import "../mixins/LeafCalculatorMixin.sol";
1112
import "./OperatorTableUpdaterStorage.sol";
1213

1314
contract OperatorTableUpdater is
@@ -16,6 +17,7 @@ contract OperatorTableUpdater is
1617
Pausable,
1718
OperatorTableUpdaterStorage,
1819
SemVerMixin,
20+
LeafCalculatorMixin,
1921
ReentrancyGuardUpgradeable
2022
{
2123
/**
@@ -148,7 +150,7 @@ contract OperatorTableUpdater is
148150
globalTableRoot: globalTableRoot,
149151
operatorSetIndex: operatorSetIndex,
150152
proof: proof,
151-
operatorSetLeafHash: keccak256(operatorTableBytes)
153+
operatorSetLeafHash: calculateOperatorTableLeaf(operatorTableBytes)
152154
});
153155

154156
// Update the operator table

src/contracts/multichain/OperatorTableUpdaterStorage.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ abstract contract OperatorTableUpdaterStorage is IOperatorTableUpdater {
1414
/// @notice Index for flag that pauses calling `updateOperatorTable`
1515
uint8 internal constant PAUSED_OPERATOR_TABLE_UPDATE = 1;
1616

17+
// OPERATOR_TABLE_LEAF_SALT is now inherited from LeafCalculatorMixin
18+
1719
bytes32 public constant GLOBAL_TABLE_ROOT_CERT_TYPEHASH =
1820
keccak256("GlobalTableRootCert(bytes32 globalTableRoot,uint32 referenceTimestamp,uint32 referenceBlockNumber)");
1921

src/test/integration/MultichainIntegrationBase.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ abstract contract MultichainIntegrationBase is IntegrationBase {
768768
);
769769

770770
// Create global table root containing the operator table
771-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
771+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
772772
bytes32[] memory leaves = new bytes32[](1);
773773
leaves[0] = operatorSetLeafHash;
774774
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -798,7 +798,7 @@ abstract contract MultichainIntegrationBase is IntegrationBase {
798798
);
799799

800800
// Create global table root containing the operator table
801-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
801+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
802802
bytes32[] memory leaves = new bytes32[](1);
803803
leaves[0] = operatorSetLeafHash;
804804
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);

src/test/integration/tests/Multichain_Timing_Tests.t.sol

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
5454
abi.encode(operatorSetInfo)
5555
);
5656

57-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
57+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
5858
bytes32[] memory leaves = new bytes32[](1);
5959
leaves[0] = operatorSetLeafHash;
6060
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -119,7 +119,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
119119
abi.encode(operatorSetInfo)
120120
);
121121

122-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
122+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
123123
bytes32[] memory leaves = new bytes32[](1);
124124
leaves[0] = operatorSetLeafHash;
125125
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -243,7 +243,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
243243
abi.encode(operatorSetInfo)
244244
);
245245

246-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
246+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
247247
bytes32[] memory leaves = new bytes32[](1);
248248
leaves[0] = operatorSetLeafHash;
249249
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -277,7 +277,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
277277
abi.encode(operatorSetInfo)
278278
);
279279

280-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
280+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
281281
bytes32[] memory leaves = new bytes32[](1);
282282
leaves[0] = operatorSetLeafHash;
283283
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -626,7 +626,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
626626
);
627627

628628
// Create global table root containing the operator table
629-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
629+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
630630
bytes32[] memory leaves = new bytes32[](1);
631631
leaves[0] = operatorSetLeafHash;
632632
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);
@@ -659,7 +659,7 @@ contract Multichain_Timing_Tests is MultichainIntegrationCheckUtils {
659659
);
660660

661661
// Create global table root containing the operator table
662-
bytes32 operatorSetLeafHash = keccak256(operatorTable);
662+
bytes32 operatorSetLeafHash = operatorTableUpdater.calculateOperatorTableLeaf(operatorTable);
663663
bytes32[] memory leaves = new bytes32[](1);
664664
leaves[0] = operatorSetLeafHash;
665665
bytes32 globalTableRoot = Merkle.merkleizeKeccak(leaves);

0 commit comments

Comments
 (0)