Skip to content

Commit e9a05f6

Browse files
authored
Cx receipt fixes main (#5101)
* fix(quorum): count enabled bitmap bits, not committee size, for pre-staking quorum uniformVerifier.IsQuorumAchievedByMask compared the full committee size (len(mask.Publics)) against the threshold instead of counting bits actually set in the signer bitmap. An all-zero bitmap paired with an all-zero (identity) aggregate BLS signature therefore satisfied quorum for any pre-staking-epoch committee, since len(mask.Publics) always exceeds the threshold regardless of how many signers were enabled. Count enabled bits via mask.IndexEnabled and reject a nil mask outright. Add regression coverage: a mask-level table test for zero/below-threshold/ at-threshold/padding-bit bitmaps, and an engine-level test asserting that verifySignature rejects an empty pre-staking quorum end-to-end. * fix(core): key CX-receipt spent-marker off the signed header, not MerkleProof IsSpent and WriteCXReceiptsProofSpent only derived the spent-marker key from the signed block.Header (ShardID/Number) from IsCXMerkleProofReplayFixEpoch onward. For any proof whose Header.Epoch() precedes that fork epoch, the key fell back to CXMerkleProof.ShardID/BlockNum -- fields that ValidateCXReceiptsProof never binds to the Header for those epochs. Since the Header (and therefore its signature) can't be altered without invalidating VerifyHeaderSignature, but MerkleProof.ShardID/BlockNum are otherwise unauthenticated, a genuine, already-applied CXReceiptsProof could be resubmitted with a mutated MerkleProof identity: each resubmission hashed to a different, "unspent" key, letting ApplyIncomingReceipt credit the destination again with no corresponding source debit. Always derive the spent-marker key from the Header. This doesn't change what ValidateCXReceiptsProof accepts, and is a no-op for honestly generated proofs -- MerkleProof.ShardID/BlockNum are naturally populated from the same source block as the Header, so the on-disk key is unchanged for real historical data; only a proof with a deliberately mismatched MerkleProof identity resolves to a different (correct) key than before. Add TestIsSpentIgnoresMutatedMerkleProofIdentity covering the replay case.
1 parent db22d51 commit e9a05f6

5 files changed

Lines changed: 137 additions & 10 deletions

File tree

‎consensus/quorum/quorom_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ func TestPolicyStrings(t *testing.T) {
5252
}
5353
}
5454

55+
func TestUniformVerifierQuorumByMask(t *testing.T) {
56+
verifier := &uniformVerifier{pubKeyCnt: 4}
57+
publics := make([]bls.PublicKeyWrapper, verifier.pubKeyCnt)
58+
for i := range publics {
59+
publics[i].Object = bls.RandPrivateKey().GetPublicKey()
60+
publics[i].Bytes.FromLibBLSPublicKey(publics[i].Object)
61+
}
62+
63+
tests := []struct {
64+
name string
65+
bitmap []byte
66+
quorum bool
67+
}{
68+
{name: "no signers", bitmap: []byte{0x00}, quorum: false},
69+
{name: "below threshold", bitmap: []byte{0x03}, quorum: false},
70+
{name: "at threshold", bitmap: []byte{0x07}, quorum: true},
71+
{name: "padding bits", bitmap: []byte{0xf0}, quorum: false},
72+
{name: "nil mask", bitmap: nil, quorum: false},
73+
}
74+
75+
for _, test := range tests {
76+
t.Run(test.name, func(t *testing.T) {
77+
var mask *bls.Mask
78+
if test.bitmap != nil {
79+
mask = bls.NewMask(publics)
80+
require.NoError(t, mask.SetMask(test.bitmap))
81+
}
82+
assert.Equal(t, test.quorum, verifier.IsQuorumAchievedByMask(mask))
83+
})
84+
}
85+
}
86+
5587
func TestAddingQuoromParticipants(t *testing.T) {
5688
decider := NewDecider(SuperMajorityVote, shard.BeaconChainShardID)
5789

‎consensus/quorum/verifier.go‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,18 @@ func newUniformVerifier(committee *shard.Committee) (*uniformVerifier, error) {
7070
}, nil
7171
}
7272

73-
// IsQuorumAchievedByMask returns whether the quorum is achieved with the provided mask,
74-
// which is whether more than (2/3+1) nodes is included in mask.
73+
// IsQuorumAchievedByMask returns whether the quorum is achieved with the provided mask.
7574
func (uv *uniformVerifier) IsQuorumAchievedByMask(mask *bls_cosi.Mask) bool {
76-
got := int64(len(mask.Publics))
77-
exp := uv.thresholdKeyCount()
78-
// Theoretically speaking, greater or equal will do the work. But current logic is more strict
79-
// without equal, thus conform to current logic implemented.
80-
// (engineImpl.VerifySeal, uniformVoteWeight.IsQuorumAchievedByMask)
81-
return got > exp
75+
if mask == nil {
76+
return false
77+
}
78+
var signerCount int64
79+
for i := range mask.Publics {
80+
if enabled, err := mask.IndexEnabled(i); err == nil && enabled {
81+
signerCount++
82+
}
83+
}
84+
return signerCount >= uv.thresholdKeyCount()
8285
}
8386

8487
func (uv *uniformVerifier) thresholdKeyCount() int64 {

‎core/blockchain_impl.go‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2708,7 +2708,14 @@ func (bc *BlockChainImpl) CXMerkleProof(toShardID uint32, block *block.Header) (
27082708

27092709
func (bc *BlockChainImpl) WriteCXReceiptsProofSpent(db rawdb.DatabaseWriter, cxps []*types.CXReceiptsProof) error {
27102710
for _, cxp := range cxps {
2711-
if cxp.Header != nil && bc.Config().IsCXMerkleProofReplayFixEpoch(cxp.Header.Epoch()) {
2711+
// Key the spent-marker off the signed Header, not the unauthenticated
2712+
// MerkleProof.ShardID/BlockNum: those fields are only bound to the
2713+
// Header by ValidateCXReceiptsProof from IsCXMerkleProofReplayFixEpoch
2714+
// onward, so a proof claiming an earlier epoch can carry a mutated
2715+
// MerkleProof while keeping a genuine Header/signature. Deriving the
2716+
// key from MerkleProof would let such a mutated copy of an already
2717+
//-applied receipt look unspent and be replayed for a fresh credit.
2718+
if cxp.Header != nil {
27122719
if err := rawdb.WriteCXReceiptsProofSpentWithKey(
27132720
db, cxp.Header.ShardID(), cxp.Header.Number().Uint64(),
27142721
); err != nil {
@@ -2726,7 +2733,10 @@ func (bc *BlockChainImpl) WriteCXReceiptsProofSpent(db rawdb.DatabaseWriter, cxp
27262733
func (bc *BlockChainImpl) IsSpent(cxp *types.CXReceiptsProof) bool {
27272734
shardID := cxp.MerkleProof.ShardID
27282735
blockNum := cxp.MerkleProof.BlockNum.Uint64()
2729-
if cxp.Header != nil && bc.Config().IsCXMerkleProofReplayFixEpoch(cxp.Header.Epoch()) {
2736+
// See WriteCXReceiptsProofSpent: always resolve the spent-marker key from
2737+
// the signed Header so the check can't be bypassed by mutating the
2738+
// unauthenticated MerkleProof fields on a genuine, previously-applied proof.
2739+
if cxp.Header != nil {
27302740
shardID = cxp.Header.ShardID()
27312741
blockNum = cxp.Header.Number().Uint64()
27322742
}

‎core/blockchain_impl_test.go‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,51 @@ import (
1111
staking "github.com/harmony-one/harmony/staking/types"
1212
)
1313

14+
// TestIsSpentIgnoresMutatedMerkleProofIdentity guards against replaying a
15+
// genuine, already-applied CXReceiptsProof by mutating the unauthenticated
16+
// MerkleProof.ShardID/BlockNum while keeping the same signed Header: the
17+
// spent-marker must be keyed off the Header, which cannot be altered without
18+
// invalidating the commit signature, not off MerkleProof fields that
19+
// ValidateCXReceiptsProof only binds to the Header from
20+
// IsCXMerkleProofReplayFixEpoch onward.
21+
func TestIsSpentIgnoresMutatedMerkleProofIdentity(t *testing.T) {
22+
key, _ := crypto.GenerateKey()
23+
chain, _, header, database := getTestEnvironment(*key)
24+
25+
header = header.With().ShardID(1).Number(big.NewInt(42)).Header()
26+
27+
original := &types.CXReceiptsProof{
28+
Header: header,
29+
MerkleProof: &types.CXMerkleProof{
30+
ShardID: 1,
31+
BlockNum: big.NewInt(42),
32+
},
33+
}
34+
35+
batch := database.NewBatch()
36+
if err := chain.WriteCXReceiptsProofSpent(batch, []*types.CXReceiptsProof{original}); err != nil {
37+
t.Fatalf("WriteCXReceiptsProofSpent failed: %v", err)
38+
}
39+
if err := batch.Write(); err != nil {
40+
t.Fatalf("batch.Write failed: %v", err)
41+
}
42+
43+
if !chain.IsSpent(original) {
44+
t.Fatal("expected original proof to be marked spent")
45+
}
46+
47+
replay := &types.CXReceiptsProof{
48+
Header: header, // same genuine, signed header
49+
MerkleProof: &types.CXMerkleProof{
50+
ShardID: 99, // mutated, unauthenticated
51+
BlockNum: big.NewInt(9999), // mutated, unauthenticated
52+
},
53+
}
54+
if !chain.IsSpent(replay) {
55+
t.Fatal("expected replay with mutated MerkleProof identity to be detected as already spent")
56+
}
57+
}
58+
1459
func TestPrepareStakingMetadata(t *testing.T) {
1560
key, _ := crypto.GenerateKey()
1661
chain, db, header, _ := getTestEnvironment(*key)

‎internal/chain/engine_test.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/harmony-one/harmony/block"
1212
blockfactory "github.com/harmony-one/harmony/block/factory"
1313
"github.com/harmony-one/harmony/consensus/engine"
14+
"github.com/harmony-one/harmony/consensus/quorum"
1415
consensus_sig "github.com/harmony-one/harmony/consensus/signature"
1516
"github.com/harmony-one/harmony/crypto/bls"
1617
"github.com/harmony-one/harmony/numeric"
@@ -498,6 +499,42 @@ func TestVerifiedSigCacheKeyIncludesShardID(t *testing.T) {
498499
}
499500
}
500501

502+
func TestVerifySignatureRejectsEmptyPreStakingQuorum(t *testing.T) {
503+
chain := makeFakeBlockChain()
504+
eng := NewEngine()
505+
state := makeDefaultCommittee()
506+
committee, err := state.FindCommitteeByID(shard.BeaconChainShardID)
507+
if err != nil {
508+
t.Fatal(err)
509+
}
510+
pubKeys, err := committee.BLSPublicKeys()
511+
if err != nil {
512+
t.Fatal(err)
513+
}
514+
verifier, err := quorum.NewVerifier(committee, big.NewInt(0), false)
515+
if err != nil {
516+
t.Fatal(err)
517+
}
518+
eng.epochCtxCache.Add(epochCtxKey{shardID: shard.BeaconChainShardID, epoch: 0}, epochCtx{
519+
qrVerifier: verifier,
520+
pubKeys: pubKeys,
521+
})
522+
523+
err = eng.verifySignature(chain, payloadArgs{
524+
blockHash: common.Hash{1},
525+
shardID: shard.BeaconChainShardID,
526+
epoch: big.NewInt(0),
527+
number: 2,
528+
viewID: 1,
529+
}, sigArgs{
530+
sig: bls.SerializedSignature{},
531+
bitmap: make([]byte, (len(pubKeys)+7)/8),
532+
})
533+
if err == nil || err.Error() != "not enough signature collected" {
534+
t.Fatalf("expected empty quorum rejection, got %v", err)
535+
}
536+
}
537+
501538
// setupTimestampValidationChain returns a chain wired with the timestamp
502539
// validation fork active, the parent header committed at parentTime, and a
503540
// helper to build child headers parented to it.

0 commit comments

Comments
 (0)