Skip to content

Commit 262ba39

Browse files
mergify[bot]rootulpclaude
authored
fix: prevent CompactBlock.Proofs() cache poisoning on validation failure (backport #2847) (#2934)
Fixes https://dashboard.hackenproof.com/manager/companies/celestia/celestia/reports/CELESTIA-216 ## Summary - `CompactBlock.Proofs()` populated `proofsCache` before verifying Merkle root hashes. When verification failed, the non-nil cache caused subsequent calls to return invalid proofs with a nil error, bypassing the integrity check. - Fix uses a temporary slice during computation and only commits to `proofsCache` after both root hash validations pass. - Adds `TestProofsCachePoisoning` covering original root mismatch, parity root mismatch, and valid block (regression) cases. ## Test plan - [x] `TestProofsCachePoisoning/original_root_mismatch_poisons_cache` — verifies second call still returns error - [x] `TestProofsCachePoisoning/parity_root_mismatch_poisons_cache` — verifies parity path - [x] `TestProofsCachePoisoning/valid_compact_block_still_works` — regression for happy path + cache reuse - [x] Full `consensus/propagation/types` test suite passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- <a href="https://app.devin.ai/review/celestiaorg/celestia-core/pull/2847" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <hr>This is an automatic backport of pull request #2847 done by [Mergify](https://mergify.com). <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/celestiaorg/celestia-core/pull/2934" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: Rootul P <rootulp@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f61ea3a commit 262ba39

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

consensus/propagation/types/types.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,22 +206,23 @@ func (c *CompactBlock) Proofs() ([]*merkle.Proof, error) {
206206
return nil, errors.New("invalid number of partset hashes")
207207
}
208208

209-
c.proofsCache = make([]*merkle.Proof, 0, len(c.PartsHashes))
209+
tempProofs := make([]*merkle.Proof, 0, len(c.PartsHashes))
210210

211211
root, proofs := merkle.ParallelProofsFromLeafHashes(c.PartsHashes[:total])
212-
c.proofsCache = append(c.proofsCache, proofs...)
212+
tempProofs = append(tempProofs, proofs...)
213213

214214
if !bytes.Equal(root, c.Proposal.BlockID.PartSetHeader.Hash) {
215-
return c.proofsCache, fmt.Errorf("incorrect PartsHash: original root")
215+
return nil, fmt.Errorf("incorrect PartsHash: original root")
216216
}
217217

218218
parityRoot, eproofs := merkle.ParallelProofsFromLeafHashes(c.PartsHashes[total:])
219-
c.proofsCache = append(c.proofsCache, eproofs...)
219+
tempProofs = append(tempProofs, eproofs...)
220220

221221
if !bytes.Equal(c.BpHash, parityRoot) {
222-
return c.proofsCache, fmt.Errorf("incorrect PartsHash: parity root")
222+
return nil, fmt.Errorf("incorrect PartsHash: parity root")
223223
}
224224

225+
c.proofsCache = tempProofs
225226
return c.proofsCache, nil
226227
}
227228

consensus/propagation/types/types_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -876,6 +876,113 @@ func TestRecoveryPart_ValidateBasic(t *testing.T) {
876876
}
877877
}
878878

879+
// TestProofsCachePoisoning verifies that Proofs() does not cache invalid proofs.
880+
// If the root hash verification fails on the first call, subsequent calls must
881+
// also return an error rather than serving the invalid proofs from cache.
882+
func TestProofsCachePoisoning(t *testing.T) {
883+
const numParts = 4
884+
885+
// Generate valid leaf hashes and compute the correct Merkle roots.
886+
originalHashes := make([][]byte, numParts)
887+
parityHashes := make([][]byte, numParts)
888+
for i := 0; i < numParts; i++ {
889+
originalHashes[i] = rand.Bytes(tmhash.Size)
890+
parityHashes[i] = rand.Bytes(tmhash.Size)
891+
}
892+
893+
originalRoot, _ := merkle.ParallelProofsFromLeafHashes(originalHashes)
894+
parityRoot, _ := merkle.ParallelProofsFromLeafHashes(parityHashes)
895+
896+
t.Run("original root mismatch poisons cache", func(t *testing.T) {
897+
// Build PartsHashes with manipulated original hashes so the computed root won't match.
898+
partsHashes := make([][]byte, numParts*ParityRatio)
899+
for i := 0; i < numParts; i++ {
900+
partsHashes[i] = rand.Bytes(tmhash.Size) // manipulated
901+
}
902+
copy(partsHashes[numParts:], parityHashes)
903+
904+
cb := &CompactBlock{
905+
Proposal: types.Proposal{
906+
BlockID: types.BlockID{
907+
PartSetHeader: types.PartSetHeader{
908+
Total: uint32(numParts),
909+
Hash: originalRoot, // correct root
910+
},
911+
},
912+
},
913+
BpHash: parityRoot,
914+
PartsHashes: partsHashes,
915+
}
916+
917+
// First call: should fail because manipulated hashes don't match originalRoot.
918+
_, err := cb.Proofs()
919+
require.Error(t, err, "first call should return an error for root hash mismatch")
920+
921+
// Second call: must also fail. This is the bug — without the fix,
922+
// proofsCache is non-nil so Proofs() returns (cache, nil).
923+
_, err = cb.Proofs()
924+
require.Error(t, err, "second call must also return an error; cache should not hide the failure")
925+
})
926+
927+
t.Run("parity root mismatch poisons cache", func(t *testing.T) {
928+
// Build PartsHashes with correct original hashes but manipulated parity hashes.
929+
partsHashes := make([][]byte, numParts*ParityRatio)
930+
copy(partsHashes, originalHashes)
931+
for i := 0; i < numParts; i++ {
932+
partsHashes[numParts+i] = rand.Bytes(tmhash.Size) // manipulated
933+
}
934+
935+
cb := &CompactBlock{
936+
Proposal: types.Proposal{
937+
BlockID: types.BlockID{
938+
PartSetHeader: types.PartSetHeader{
939+
Total: uint32(numParts),
940+
Hash: originalRoot,
941+
},
942+
},
943+
},
944+
BpHash: parityRoot,
945+
PartsHashes: partsHashes,
946+
}
947+
948+
// First call: original root matches, but parity root will not.
949+
_, err := cb.Proofs()
950+
require.Error(t, err, "first call should return an error for parity root mismatch")
951+
952+
// Second call: must also fail.
953+
_, err = cb.Proofs()
954+
require.Error(t, err, "second call must also return an error; cache should not hide the failure")
955+
})
956+
957+
t.Run("valid compact block still works", func(t *testing.T) {
958+
partsHashes := make([][]byte, numParts*ParityRatio)
959+
copy(partsHashes, originalHashes)
960+
copy(partsHashes[numParts:], parityHashes)
961+
962+
cb := &CompactBlock{
963+
Proposal: types.Proposal{
964+
BlockID: types.BlockID{
965+
PartSetHeader: types.PartSetHeader{
966+
Total: uint32(numParts),
967+
Hash: originalRoot,
968+
},
969+
},
970+
},
971+
BpHash: parityRoot,
972+
PartsHashes: partsHashes,
973+
}
974+
975+
proofs, err := cb.Proofs()
976+
require.NoError(t, err)
977+
require.Len(t, proofs, numParts*ParityRatio)
978+
979+
// Second call should also succeed from cache.
980+
proofs2, err := cb.Proofs()
981+
require.NoError(t, err)
982+
require.Equal(t, proofs, proofs2)
983+
})
984+
}
985+
879986
func makeBlockID(hash []byte, partSetSize uint32, partSetHash []byte) types.BlockID {
880987
var (
881988
h = make([]byte, tmhash.Size)

0 commit comments

Comments
 (0)