diff --git a/cmd/harmony/main.go b/cmd/harmony/main.go index 791bf21816..49f5a52e63 100644 --- a/cmd/harmony/main.go +++ b/cmd/harmony/main.go @@ -783,16 +783,41 @@ func setupConsensusAndNode(hc harmonyconfig.HarmonyConfig, nodeConfig *nodeconfi Msg("InitConsensusWithMembers failed") } - // Set the consensus ID to be the current block number + // Offline maintenance never starts consensus signing, so it must remain + // usable for preflight and rollback even before release constants are filled. + // Every networked validator installs the floor before any signing service. + if !hc.General.IsOffline { + if err := currentConsensus.ConfigureEmergencyRecoveryViewIDFloor(); err != nil { + utils.Logger().Panic().Err(err). + Msg("refusing to start without a valid emergency recovery ViewID floor") + } + } + + // Set the consensus ID to the checked successor of the retained head's view. viewID := currentNode.Blockchain().CurrentBlock().Header().ViewID().Uint64() - currentConsensus.SetViewIDs(viewID + 1) + nextViewID, err := consensus.CheckedNextViewID(viewID) + if err != nil { + utils.Logger().Panic().Err(err). + Uint64("headViewID", viewID). + Msg("refusing to start because the head ViewID cannot advance") + } + currentConsensus.SetViewIDs(nextViewID) utils.Logger().Info(). - Uint64("viewID", viewID). + Uint64("headViewID", viewID). + Uint64("consensusViewID", currentConsensus.GetCurBlockViewID()). Msg("Init Blockchain") currentNode.Consensus.Registry().SetNodeConfig(currentNode.NodeConfig) - // update consensus information based on the blockchain - currentConsensus.SetMode(currentConsensus.UpdateConsensusInformation("setupConsensusAndNode")) + // Update the committee first, then derive the leader from the exact recovery + // view gap before StartChannel can trigger a proposal. + mode := currentConsensus.UpdateConsensusInformation("setupConsensusAndNode") + if !hc.General.IsOffline { + if err := currentConsensus.InitializeEmergencyRecoveryLeader(); err != nil { + utils.Logger().Panic().Err(err). + Msg("refusing to start without a deterministic emergency recovery leader") + } + } + currentConsensus.SetMode(mode) currentConsensus.NextBlockDue = time.Now() return currentNode } diff --git a/consensus/checks.go b/consensus/checks.go index 739c19dd6c..e6feaed9d3 100644 --- a/consensus/checks.go +++ b/consensus/checks.go @@ -142,6 +142,12 @@ func (consensus *Consensus) newBlockSanityChecks( // TODO: leo: move the sanity check to p2p message validation func (consensus *Consensus) onViewChangeSanityCheck(recvMsg *FBFTMessage) bool { + if err := consensus.assertEmergencyRecoveryViewID(recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgViewChangingID", recvMsg.ViewID). + Msg("[onViewChangeSanityCheck] rejected ViewID below recovery floor") + return false + } // TODO: if difference is only one, new leader can still propose the same committed block to avoid another view change // TODO: new leader catchup without ignore view change message @@ -194,6 +200,12 @@ func (consensus *Consensus) onViewChangeSanityCheck(recvMsg *FBFTMessage) bool { // TODO: leo: move the sanity check to p2p message validation func (consensus *Consensus) onNewViewSanityCheck(recvMsg *FBFTMessage) bool { + if err := consensus.assertEmergencyRecoveryViewID(recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgViewChangingID", recvMsg.ViewID). + Msg("[onNewView] rejected ViewID below recovery floor") + return false + } if recvMsg.ViewID < consensus.getCurBlockViewID() { consensus.getLogger().Warn(). Uint64("LastSuccessfulConsensusViewID", consensus.getCurBlockViewID()). @@ -203,3 +215,21 @@ func (consensus *Consensus) onNewViewSanityCheck(recvMsg *FBFTMessage) bool { } return true } + +func (consensus *Consensus) validateExpectedNewViewLeader(sender *bls.PublicKeyWrapper, viewID uint64) error { + if sender == nil { + return errors.New("NEWVIEW sender is missing") + } + expected := consensus.getLeaderPubKey() + if consensus.current.GetViewIDFloor() > 0 { + var err error + expected, err = consensus.expectedLeaderForViewID(viewID) + if err != nil { + return err + } + } + if expected == nil || sender.Bytes != expected.Bytes { + return errors.New("NEWVIEW sender is not the selected leader for ViewID") + } + return nil +} diff --git a/consensus/consensus_block_proposing.go b/consensus/consensus_block_proposing.go index 64200f93b1..c200d96d05 100644 --- a/consensus/consensus_block_proposing.go +++ b/consensus/consensus_block_proposing.go @@ -23,6 +23,9 @@ const ( // ProposeNewBlock proposes a new block... func (consensus *Consensus) ProposeNewBlock(now time.Time, commitSigs chan []byte) (*types.Block, error) { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getCurBlockViewID()); err != nil { + return nil, errors.Wrap(err, "refusing to propose with unsafe ViewID") + } var ( currentHeader = consensus.Blockchain().CurrentHeader() nowEpoch = currentHeader.Epoch() diff --git a/consensus/consensus_service.go b/consensus/consensus_service.go index 0cc54f8829..fb5a342f97 100644 --- a/consensus/consensus_service.go +++ b/consensus/consensus_service.go @@ -268,6 +268,9 @@ func (consensus *Consensus) RegisterRndChannel(rndChannel chan [548]byte) { // Check viewID, caller's responsibility to hold lock when change ignoreViewIDCheck func (consensus *Consensus) checkViewID(msg *FBFTMessage) error { + if err := consensus.assertEmergencyRecoveryViewID(msg.ViewID); err != nil { + return err + } // just ignore consensus check for the first time when node join if consensus.IgnoreViewIDCheck.IsSet() { //in syncing mode, node accepts incoming messages without viewID/leaderKey checking @@ -494,8 +497,9 @@ func (consensus *Consensus) updateConsensusInformation(reason string) Mode { } // If the leader changed and I myself become the leader - if (oldLeader != nil && consensus.getLeaderPubKey() != nil && - !consensus.getLeaderPubKey().Object.IsEqual(oldLeader.Object)) && consensus.isLeader() { + if consensus.current.GetViewIDFloor() == 0 && + (oldLeader != nil && consensus.getLeaderPubKey() != nil && + !consensus.getLeaderPubKey().Object.IsEqual(oldLeader.Object)) && consensus.isLeader() { go func() { consensus.GetLogger().Info(). Str("myKey", myPubKeys.SerializeToHexStr()). @@ -619,6 +623,9 @@ func (consensus *Consensus) selfCommit(payload []byte) error { if block == nil { return errGetPreparedBlock } + if err := consensus.assertEmergencyRecoveryBlockViewID(block.Header().ViewID().Uint64()); err != nil { + return err + } aggSig, mask, err := readSignatureBitmapPayload(payload, 32, consensus.decider().Participants()) if err != nil { diff --git a/consensus/consensus_v2.go b/consensus/consensus_v2.go index 728a964687..0b8af08c2e 100644 --- a/consensus/consensus_v2.go +++ b/consensus/consensus_v2.go @@ -377,8 +377,16 @@ func (consensus *Consensus) StartChannel() { func (consensus *Consensus) syncReadyChan(reason string) { consensus.getLogger().Info().Msgf("[ConsensusMainLoop] syncReadyChan %s", reason) if consensus.getBlockNum() < consensus.Blockchain().CurrentHeader().Number().Uint64()+1 { - consensus.setBlockNum(consensus.Blockchain().CurrentHeader().Number().Uint64() + 1) - consensus.setViewIDs(consensus.Blockchain().CurrentHeader().ViewID().Uint64() + 1) + currentHeader := consensus.Blockchain().CurrentHeader() + nextViewID, err := checkedNextViewID(currentHeader.ViewID().Uint64()) + if err != nil { + consensus.getLogger().Error().Err(err). + Uint64("headViewID", currentHeader.ViewID().Uint64()). + Msg("[syncReadyChan] refusing to reset to an exhausted ViewID") + return + } + consensus.setBlockNum(currentHeader.Number().Uint64() + 1) + consensus.setViewIDs(nextViewID) mode := consensus.updateConsensusInformation(reason) consensus.current.SetMode(mode) consensus.getLogger().Info().Msg("[syncReadyChan] Start consensus timer") @@ -560,6 +568,11 @@ func (consensus *Consensus) preCommitAndPropose(blk *types.Block) error { if blk == nil { return errors.New("block to pre-commit is nil") } + if consensus.current.GetViewIDFloor() > 0 { + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blk, consensus.getCurBlockViewID()); err != nil { + return err + } + } leaderPriKey, err := consensus.getConsensusLeaderPrivateKey() if err != nil { @@ -579,13 +592,19 @@ func (consensus *Consensus) preCommitAndPropose(blk *types.Block) error { network.Bytes, network.FBFTMsg bareMinimumCommit := FBFTMsg.Payload - consensus.fBFTLog.AddVerifiedMessage(FBFTMsg) if err := consensus.verifyLastCommitSig(bareMinimumCommit, blk); err != nil { return errors.Wrap(err, "[preCommitAndPropose] failed verifying last commit sig") } + consensus.fBFTLog.AddVerifiedMessage(FBFTMsg) go func() { + if consensus.current.GetViewIDFloor() > 0 { + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blk, FBFTMsg.ViewID); err != nil { + consensus.GetLogger().Error().Err(err).Msg("[preCommitAndPropose] unsafe recovery ViewID") + return + } + } blk.SetCurrentCommitSig(bareMinimumCommit) // Send committed message to validators since 2/3 commit is already collected @@ -675,6 +694,13 @@ func (consensus *Consensus) tryCatchup() error { if blk == nil { return nil } + if msg == nil { + return errors.New("[TryCatchup] committed message is missing") + } + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blk, msg.ViewID); err != nil { + consensus.getLogger().Error().Err(err).Msg("[TryCatchup] unsafe recovery ViewID") + return err + } blk.SetCurrentCommitSig(msg.Payload) if err := consensus.verifyBlock(blk); err != nil { @@ -700,6 +726,12 @@ func (consensus *Consensus) tryCatchup() error { } func (consensus *Consensus) commitBlock(blk *types.Block, committedMsg *FBFTMessage) error { + if committedMsg == nil { + return errors.New("committed message is missing") + } + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blk, committedMsg.ViewID); err != nil { + return err + } if consensus.Blockchain().CurrentBlock().NumberU64() < blk.NumberU64() { _, err := consensus.Blockchain().InsertChain([]*types.Block{blk}, !consensus.fBFTLog.IsBlockVerified(blk.Hash())) if err != nil && !errors.Is(err, core.ErrKnownBlock) { @@ -715,7 +747,9 @@ func (consensus *Consensus) commitBlock(blk *types.Block, committedMsg *FBFTMess consensus.FinishFinalityCount() consensus.postConsensusProcessing(blk) - consensus.setupForNewConsensus(blk, committedMsg) + if err := consensus.setupForNewConsensus(blk, committedMsg); err != nil { + return err + } consensus.getLogger().Info().Uint64("blockNum", blk.NumberU64()). Str("hash", blk.Header().Hash().Hex()). Msg("Added New Block to Blockchain!!!") @@ -840,9 +874,13 @@ func (consensus *Consensus) rotateLeader(epoch *big.Int, defaultKey *bls.PublicK } // SetupForNewConsensus sets the state for new consensus -func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg *FBFTMessage) { +func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg *FBFTMessage) error { + nextViewID, err := checkedNextViewID(committedMsg.ViewID) + if err != nil { + return errors.Wrap(err, "cannot advance consensus ViewID") + } consensus.setBlockNum(blk.NumberU64() + 1) - consensus.setCurBlockViewID(committedMsg.ViewID + 1) + consensus.setCurBlockViewID(nextViewID) var epoch *big.Int if blk.IsLastBlockInEpoch() { epoch = new(big.Int).Add(blk.Epoch(), common.Big1) @@ -897,6 +935,7 @@ func (consensus *Consensus) setupForNewConsensus(blk *types.Block, committedMsg consensus.fBFTLog.PruneCacheBeforeBlock(blk.NumberU64()) consensus.resetState() consensus.sendLastSignPower() + return nil } func (consensus *Consensus) postCatchup(initBN uint64) { diff --git a/consensus/construct.go b/consensus/construct.go index dab4026918..b68ae04507 100644 --- a/consensus/construct.go +++ b/consensus/construct.go @@ -58,6 +58,15 @@ func (pm *State) populateMessageFieldsAndSender( func (consensus *Consensus) construct( p msg_pb.MessageType, payloadForSign []byte, priKeys []*bls.PrivateKeyWrapper, ) (*NetworkMessage, error) { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getCurBlockViewID()); err != nil { + return nil, err + } + switch p { + case msg_pb.MessageType_ANNOUNCE, msg_pb.MessageType_PREPARE, msg_pb.MessageType_PREPARED: + if err := consensus.validateCurrentConsensusBlockViewID(); err != nil { + return nil, err + } + } if len(priKeys) == 0 { return nil, errors.New("no elected bls keys provided") } diff --git a/consensus/leader.go b/consensus/leader.go index af7e39a0d7..8453dd913b 100644 --- a/consensus/leader.go +++ b/consensus/leader.go @@ -18,6 +18,10 @@ import ( // announce fires leader func (consensus *Consensus) announce(block *types.Block) { + if err := consensus.assertEmergencyRecoveryBlockViewID(block.Header().ViewID().Uint64()); err != nil { + consensus.getLogger().Error().Err(err).Msg("[Announce] unsafe recovery ViewID") + return + } blockHash := block.Hash() // prepare message and broadcast to validators @@ -269,6 +273,12 @@ func (consensus *Consensus) onCommit(recvMsg *FBFTMessage) { Msg("[OnCommit] Failed finding a matching block for committed message") return } + if consensus.current.GetViewIDFloor() > 0 { + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blockObj, recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err).Msg("[OnCommit] unsafe recovery ViewID") + return + } + } commitPayload := signature.ConstructCommitPayload(consensus.Blockchain().Config(), blockObj.Epoch(), blockObj.Hash(), blockObj.NumberU64(), blockObj.Header().ViewID().Uint64()) logger = logger.With(). diff --git a/consensus/recovery_view_id.go b/consensus/recovery_view_id.go new file mode 100644 index 0000000000..ed0bf66a58 --- /dev/null +++ b/consensus/recovery_view_id.go @@ -0,0 +1,274 @@ +package consensus + +import ( + "errors" + "fmt" + "math" + "sync/atomic" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/internal/params" + "github.com/harmony-one/harmony/shard" +) + +const ( + // EmergencyRecoveryShard0RetainedBlock is the last retained shard-0 block + // for the August 2026 mainnet recovery. + EmergencyRecoveryShard0RetainedBlock uint64 = params.EmergencyRecoveryShard0RetainedBlock + + // EmergencyRecoveryShard1RetainedBlock is the last retained shard-1 block + // for the August 2026 mainnet recovery. + EmergencyRecoveryShard1RetainedBlock uint64 = params.EmergencyRecoveryShard1RetainedBlock + + // EmergencyRecoveryViewIDFloor is the recovery release's signed activation + // floor for mainnet shards 0 and 1. + EmergencyRecoveryViewIDFloor uint64 = params.EmergencyRecoveryViewIDFloor +) + +var ( + ErrEmergencyRecoveryViewIDFloorUnset = errors.New("emergency recovery ViewID floor is unset") + ErrEmergencyRecoveryViewIDBelowFloor = errors.New("ViewID is below emergency recovery floor") + ErrViewIDExhausted = errors.New("ViewID exhausted") +) + +// checkedNextViewID returns viewID+1 without permitting uint64 wraparound. +func checkedNextViewID(viewID uint64) (uint64, error) { + if viewID == math.MaxUint64 { + return 0, ErrViewIDExhausted + } + return viewID + 1, nil +} + +// CheckedNextViewID is the startup-safe form of viewID+1. +func CheckedNextViewID(viewID uint64) (uint64, error) { + return checkedNextViewID(viewID) +} + +func checkedAddViewID(a, b uint64) (uint64, error) { + if math.MaxUint64-a < b { + return 0, ErrViewIDExhausted + } + return a + b, nil +} + +func checkedLeaderViewGap(viewID, lastBlockViewID uint64) (int, error) { + firstStuckView, err := checkedNextViewID(lastBlockViewID) + if err != nil { + return 0, err + } + if viewID < firstStuckView || viewID-firstStuckView > uint64(^uint(0)>>1) { + return 0, errors.New("invalid recovery leader ViewID gap") + } + return int(viewID - firstStuckView), nil +} + +// emergencyRecoveryViewIDFloorFor scopes the one-off rule to recovered mainnet +// shards at and after each shard's retained block. The bool reports whether the +// rule applies even when its required release value is still unset. +func emergencyRecoveryViewIDFloorFor( + config *params.ChainConfig, shardID uint32, headHeight uint64, +) (floor uint64, applies bool, err error) { + if config == nil || config.ChainID == nil || + config.ChainID.Cmp(params.MainnetChainID) != 0 { + return 0, false, nil + } + + var retainedBlock uint64 + switch shardID { + case shard.BeaconChainShardID: + retainedBlock = EmergencyRecoveryShard0RetainedBlock + case 1: + retainedBlock = EmergencyRecoveryShard1RetainedBlock + default: + return 0, false, nil + } + if headHeight < retainedBlock { + return 0, false, nil + } + + if EmergencyRecoveryViewIDFloor == 0 { + return 0, true, ErrEmergencyRecoveryViewIDFloorUnset + } + if EmergencyRecoveryViewIDFloor == math.MaxUint64 { + return 0, true, fmt.Errorf("%w: emergency recovery floor is max uint64", ErrViewIDExhausted) + } + return EmergencyRecoveryViewIDFloor, true, nil +} + +// ConfigureEmergencyRecoveryViewIDFloor must run before networking or any BLS +// signing. It intentionally fails closed for an applicable build whose audited +// floor has not been filled in. +func (consensus *Consensus) ConfigureEmergencyRecoveryViewIDFloor() error { + blockchain := consensus.Blockchain() + if blockchain == nil { + return errors.New("cannot configure emergency recovery ViewID floor without blockchain") + } + header := blockchain.CurrentHeader() + if header == nil { + return errors.New("cannot configure emergency recovery ViewID floor without current header") + } + floor, applies, err := emergencyRecoveryViewIDFloorFor( + blockchain.Config(), blockchain.ShardID(), header.Number().Uint64(), + ) + if err != nil { + return err + } + if !applies { + return nil + } + + consensus.current.SetViewIDFloor(floor) + consensus.getLogger().Warn(). + Uint64("viewIDFloor", floor). + Uint64("headHeight", header.Number().Uint64()). + Msg("emergency recovery ViewID floor enabled") + return nil +} + +// InitializeEmergencyRecoveryLeader deterministically derives the leader for +// the exact effective recovery view. This prevents different nodes from +// retaining the old head's leader after a large ViewID jump. +func (consensus *Consensus) InitializeEmergencyRecoveryLeader() error { + floor := consensus.current.GetViewIDFloor() + if floor == 0 { + return nil + } + viewID := consensus.current.GetCurBlockViewID() + leader, err := consensus.expectedLeaderForViewID(viewID) + if err != nil { + return err + } + consensus.setLeaderPubKey(leader) + consensus.IgnoreViewIDCheck.UnSet() + consensus.getLogger().Warn(). + Uint64("viewID", viewID). + Str("leader", leader.Bytes.Hex()). + Msg("emergency recovery leader initialized") + return nil +} + +func (consensus *Consensus) expectedLeaderForViewID(viewID uint64) (*bls.PublicKeyWrapper, error) { + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return nil, err + } + blockchain := consensus.Blockchain() + if blockchain == nil || blockchain.CurrentHeader() == nil { + return nil, errors.New("cannot derive leader without blockchain head") + } + shardState, err := blockchain.ReadShardState(blockchain.CurrentHeader().Epoch()) + if err != nil { + return nil, fmt.Errorf("read shard state for leader selection: %w", err) + } + committee, err := shardState.FindCommitteeByID(consensus.ShardID) + if err != nil { + return nil, fmt.Errorf("find committee for leader selection: %w", err) + } + leader := consensus.current.getNextLeaderKey(blockchain, consensus.decider(), viewID, committee) + if leader == nil { + return nil, errors.New("cannot derive leader for ViewID") + } + return leader, nil +} + +func atomicMaxUint64(target *uint64, candidate uint64) uint64 { + for { + current := atomic.LoadUint64(target) + if candidate <= current { + return current + } + if atomic.CompareAndSwapUint64(target, current, candidate) { + return candidate + } + } +} + +// SetViewIDFloor raises (and can never lower) the process-local ViewID floor. +// It immediately raises both mutable ViewIDs so no signing path observes a +// value below the floor after this method returns. +func (pm *State) SetViewIDFloor(floor uint64) { + atomicMaxUint64(&pm.viewIDFloor, floor) + atomicMaxUint64(&pm.blockViewID, floor) + atomicMaxUint64(&pm.viewChangingID, floor) +} + +func (pm *State) GetViewIDFloor() uint64 { + return atomic.LoadUint64(&pm.viewIDFloor) +} + +func (pm *State) clampViewID(viewID uint64) uint64 { + if floor := pm.GetViewIDFloor(); viewID < floor { + return floor + } + return viewID +} + +// nextViewID clamps a calculated next view against both the recovery floor and +// current+1. This is the single normalization used by view-change and leader +// selection. +func (pm *State) nextViewID(calculated uint64) (uint64, error) { + nextCurrent, err := checkedNextViewID(pm.GetCurBlockViewID()) + if err != nil { + return 0, err + } + next := pm.clampViewID(calculated) + if next < nextCurrent { + next = nextCurrent + } + if next == math.MaxUint64 { + return 0, ErrViewIDExhausted + } + return next, nil +} + +func (consensus *Consensus) assertEmergencyRecoveryViewID(viewID uint64) error { + if floor := consensus.current.GetViewIDFloor(); floor != 0 { + if viewID < floor { + return fmt.Errorf("%w: got %d, floor %d", ErrEmergencyRecoveryViewIDBelowFloor, viewID, floor) + } + if viewID == math.MaxUint64 { + return ErrViewIDExhausted + } + } + return nil +} + +func (consensus *Consensus) assertEmergencyRecoveryBlockViewID(viewID uint64) error { + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return fmt.Errorf("refusing to sign block: %w", err) + } + return nil +} + +func (consensus *Consensus) validateEmergencyRecoveryMessageBlockViewID(block *types.Block, messageViewID uint64) error { + if block == nil || block.Header() == nil { + return errors.New("block is missing its header") + } + blockViewID := block.Header().ViewID().Uint64() + if blockViewID != messageViewID { + return errors.New("block ViewID does not match message ViewID") + } + return consensus.assertEmergencyRecoveryBlockViewID(blockViewID) +} + +func (consensus *Consensus) validateCurrentConsensusBlockViewID() error { + if consensus.current.GetViewIDFloor() == 0 { + return nil + } + var block types.Block + if err := rlp.DecodeBytes(consensus.current.block, &block); err != nil { + return fmt.Errorf("decode current consensus block: %w", err) + } + return consensus.validateEmergencyRecoveryMessageBlockViewID(&block, consensus.getCurBlockViewID()) +} + +func (consensus *Consensus) verifyEmergencyRecoveryBlock(block *types.Block) error { + if block == nil || block.Header() == nil { + return errors.New("block is missing its header") + } + if err := consensus.assertEmergencyRecoveryBlockViewID(block.Header().ViewID().Uint64()); err != nil { + return err + } + return consensus.verifyBlock(block) +} diff --git a/consensus/recovery_view_id_test.go b/consensus/recovery_view_id_test.go new file mode 100644 index 0000000000..44905a3500 --- /dev/null +++ b/consensus/recovery_view_id_test.go @@ -0,0 +1,253 @@ +package consensus + +import ( + "math" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/rlp" + "github.com/harmony-one/abool" + msg_pb "github.com/harmony-one/harmony/api/proto/message" + blockfactory "github.com/harmony-one/harmony/block/factory" + "github.com/harmony-one/harmony/consensus/quorum" + coretypes "github.com/harmony-one/harmony/core/types" + "github.com/harmony-one/harmony/crypto/bls" + "github.com/harmony-one/harmony/internal/params" + "github.com/harmony-one/harmony/shard" + "github.com/stretchr/testify/require" +) + +func TestEmergencyRecoveryViewIDFloorScopeFailsClosed(t *testing.T) { + require.Equal(t, uint64(1_000_000_000), EmergencyRecoveryViewIDFloor) + require.Equal(t, uint64(92_730_034), EmergencyRecoveryShard0RetainedBlock) + require.Equal(t, uint64(94_978_278), EmergencyRecoveryShard1RetainedBlock) + + mainnet := ¶ms.ChainConfig{ChainID: new(big.Int).Set(params.MainnetChainID)} + testnet := ¶ms.ChainConfig{ChainID: new(big.Int).Set(params.TestnetChainID)} + + tests := []struct { + name string + config *params.ChainConfig + shardID uint32 + headHeight uint64 + applies bool + wantErr error + }{ + {name: "nil config", shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryShard0RetainedBlock}, + {name: "testnet shard zero", config: testnet, shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryShard0RetainedBlock}, + {name: "testnet shard one", config: testnet, shardID: 1, headHeight: EmergencyRecoveryShard1RetainedBlock}, + {name: "unsupported mainnet shard", config: mainnet, shardID: 2, headHeight: EmergencyRecoveryShard1RetainedBlock}, + {name: "shard zero before retained block", config: mainnet, shardID: shard.BeaconChainShardID, headHeight: EmergencyRecoveryShard0RetainedBlock - 1}, + { + name: "shard zero at retained block", + config: mainnet, + shardID: shard.BeaconChainShardID, + headHeight: EmergencyRecoveryShard0RetainedBlock, + applies: true, + }, + { + name: "shard zero after retained block", + config: mainnet, + shardID: shard.BeaconChainShardID, + headHeight: EmergencyRecoveryShard0RetainedBlock + 1, + applies: true, + }, + {name: "shard one before retained block", config: mainnet, shardID: 1, headHeight: EmergencyRecoveryShard1RetainedBlock - 1}, + { + name: "shard one at retained block", + config: mainnet, + shardID: 1, + headHeight: EmergencyRecoveryShard1RetainedBlock, + applies: true, + }, + { + name: "shard one after retained block", + config: mainnet, + shardID: 1, + headHeight: EmergencyRecoveryShard1RetainedBlock + 1, + applies: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + floor, applies, err := emergencyRecoveryViewIDFloorFor(test.config, test.shardID, test.headHeight) + require.Equal(t, test.applies, applies) + if test.applies && EmergencyRecoveryViewIDFloor == 0 { + require.ErrorIs(t, err, ErrEmergencyRecoveryViewIDFloorUnset) + } else { + require.ErrorIs(t, err, test.wantErr) + if test.applies { + require.Equal(t, EmergencyRecoveryViewIDFloor, floor) + } + } + }) + } +} + +func TestRecoveryViewIDSettersAreFlooredAndMonotonic(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetCurBlockViewID(10) + state.SetViewChangingID(11) + state.SetViewIDFloor(100) + + require.Equal(t, uint64(100), state.GetViewIDFloor()) + require.Equal(t, uint64(100), state.GetCurBlockViewID()) + require.Equal(t, uint64(100), state.GetViewChangingID()) + + state.SetCurBlockViewID(1) + state.SetViewChangingID(2) + require.Equal(t, uint64(100), state.GetCurBlockViewID()) + require.Equal(t, uint64(100), state.GetViewChangingID()) + + state.SetCurBlockViewID(105) + state.SetViewChangingID(3) + require.Equal(t, uint64(105), state.GetCurBlockViewID()) + require.Equal(t, uint64(105), state.GetViewChangingID()) + + state.SetViewIDFloor(50) + require.Equal(t, uint64(100), state.GetViewIDFloor()) +} + +func TestOrdinaryViewIDSettersMayLowerWithoutRecoveryFloor(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetCurBlockViewID(10) + state.SetViewChangingID(11) + state.SetCurBlockViewID(3) + state.SetViewChangingID(4) + + require.Equal(t, uint64(3), state.GetCurBlockViewID()) + require.Equal(t, uint64(4), state.GetViewChangingID()) +} + +func TestRecoveryNextViewIDUsesFloorAndStrictSuccessor(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetViewIDFloor(100) + + next, err := state.nextViewID(1) + require.NoError(t, err) + require.Equal(t, uint64(101), next) + + next, _, err = state.getNextViewID(nil, nil) + require.NoError(t, err) + require.Equal(t, uint64(101), next) + + state.blockViewID = math.MaxUint64 + _, err = state.nextViewID(1) + require.ErrorIs(t, err, ErrViewIDExhausted) + _, err = checkedNextViewID(math.MaxUint64) + require.ErrorIs(t, err, ErrViewIDExhausted) + _, err = checkedAddViewID(math.MaxUint64, 1) + require.ErrorIs(t, err, ErrViewIDExhausted) + + gap, err := checkedLeaderViewGap(100, 90) + require.NoError(t, err) + require.Equal(t, 9, gap) + _, err = checkedLeaderViewGap(89, 90) + require.Error(t, err) +} + +func TestRecoveryInboundMessagesCannotLowerViewID(t *testing.T) { + consensus := &Consensus{ + current: NewState(Normal, shard.BeaconChainShardID), + IgnoreViewIDCheck: abool.NewBool(true), + } + consensus.current.SetViewIDFloor(100) + message := &FBFTMessage{ViewID: 99} + + require.ErrorIs(t, consensus.checkViewID(message), ErrEmergencyRecoveryViewIDBelowFloor) + require.False(t, consensus.onViewChangeSanityCheck(message)) + require.False(t, consensus.onNewViewSanityCheck(message)) + require.True(t, consensus.IgnoreViewIDCheck.IsSet()) +} + +func TestRecoveryConstructRefusesCorruptViewBelowFloor(t *testing.T) { + consensus := &Consensus{current: NewState(Normal, shard.BeaconChainShardID)} + consensus.current.SetViewIDFloor(100) + // Emulate memory corruption or a future call site bypassing the setter. + consensus.current.blockViewID = 99 + + _, err := consensus.construct(msg_pb.MessageType_PREPARE, nil, nil) + require.ErrorIs(t, err, ErrEmergencyRecoveryViewIDBelowFloor) +} + +func TestRecoveryValidatedAnnounceCannotMaskBlockViewID(t *testing.T) { + header := blockfactory.ForMainnet.NewHeader(big.NewInt(0)).With(). + Number(big.NewInt(1)). + Epoch(big.NewInt(0)). + ShardID(shard.BeaconChainShardID). + ViewID(big.NewInt(99)). + Header() + block := coretypes.NewBlockWithHeader(header) + blockPayload, err := rlp.EncodeToBytes(block) + require.NoError(t, err) + log := NewFBFTLog() + + consensus := &Consensus{ + current: NewState(Normal, shard.BeaconChainShardID), + fBFTLog: log, + } + consensus.current.SetViewIDFloor(100) + consensus.current.block = blockPayload + consensus.current.blockHash = block.Hash() + + _, err = consensus.validateNewBlock(&FBFTMessage{ + Block: blockPayload, + BlockHash: block.Hash(), + ViewID: 100, + }) + require.ErrorContains(t, err, "block ViewID does not match message ViewID") + require.Nil(t, log.GetBlockByHash(block.Hash())) + require.ErrorIs(t, consensus.verifyEmergencyRecoveryBlock(block), ErrEmergencyRecoveryViewIDBelowFloor) + require.ErrorIs(t, consensus.commitBlock(block, &FBFTMessage{ViewID: 99}), ErrEmergencyRecoveryViewIDBelowFloor) + privateKey := bls.RandPrivateKey() + publicKey := bls.PublicKeyWrapper{Object: privateKey.GetPublicKey()} + publicKey.Bytes.FromLibBLSPublicKey(publicKey.Object) + _, err = consensus.construct(msg_pb.MessageType_PREPARED, nil, []*bls.PrivateKeyWrapper{{Pri: privateKey, Pub: &publicKey}}) + require.ErrorContains(t, err, "block ViewID does not match message ViewID") + require.ErrorContains(t, consensus.preCommitAndPropose(block), "block ViewID does not match message ViewID") +} + +func TestNewViewLeaderSelectionDependsOnViewID(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + decider := quorum.NewDecider(quorum.SuperMajorityVote, shard.BeaconChainShardID) + wrappedKeys := make([]bls.PublicKeyWrapper, 0, 3) + for range 3 { + publicKey := bls.RandPrivateKey().GetPublicKey() + serialized := bls.SerializedPublicKey{} + serialized.FromLibBLSPublicKey(publicKey) + wrappedKeys = append(wrappedKeys, bls.PublicKeyWrapper{Object: publicKey, Bytes: serialized}) + } + decider.UpdateParticipants(wrappedKeys, nil) + state.setLeaderPubKey(&wrappedKeys[0]) + + viewOneLeader := state.getNextLeaderKey(nil, decider, 1, nil) + viewTwoLeader := state.getNextLeaderKey(nil, decider, 2, nil) + require.NotNil(t, viewOneLeader) + require.NotNil(t, viewTwoLeader) + require.True(t, viewOneLeader.Object.IsEqual(wrappedKeys[1].Object)) + require.True(t, viewTwoLeader.Object.IsEqual(wrappedKeys[2].Object)) +} + +func TestRecoveryLeaderSelectionUsesClampedNextViewID(t *testing.T) { + state := NewState(Normal, shard.BeaconChainShardID) + state.SetViewIDFloor(100) + decider := quorum.NewDecider(quorum.SuperMajorityVote, shard.BeaconChainShardID) + + wrappedKeys := make([]bls.PublicKeyWrapper, 0, 3) + for range 3 { + privateKey := bls.RandPrivateKey() + publicKey := privateKey.GetPublicKey() + serialized := bls.SerializedPublicKey{} + serialized.FromLibBLSPublicKey(publicKey) + wrappedKeys = append(wrappedKeys, bls.PublicKeyWrapper{Object: publicKey, Bytes: serialized}) + } + decider.UpdateParticipants(wrappedKeys, []bls.PublicKeyWrapper{}) + state.setLeaderPubKey(&wrappedKeys[0]) + + // Without a chain header, leader selection advances once relative to the + // current leader while still clamping the supplied view to the exact floor. + next := state.getNextLeaderKey(nil, decider, 1, nil) + require.NotNil(t, next) + require.True(t, next.Object.IsEqual(wrappedKeys[1].Object)) +} diff --git a/consensus/state.go b/consensus/state.go index 170de3f3b3..614af676d1 100644 --- a/consensus/state.go +++ b/consensus/state.go @@ -25,6 +25,10 @@ type State struct { // it is the next view id viewChangingID uint64 + // viewIDFloor is a process-local, monotonically increasing lower bound used + // by the one-off mainnet recovery. + viewIDFloor uint64 + // the publickey of leader leaderPubKey unsafe.Pointer //*bls.PublicKeyWrapper diff --git a/consensus/threshold.go b/consensus/threshold.go index e83442b3be..d800c959c4 100644 --- a/consensus/threshold.go +++ b/consensus/threshold.go @@ -45,6 +45,9 @@ func (consensus *Consensus) didReachPrepareQuorum() error { Msg("[didReachPrepareQuorum] Unparseable block data") return err } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + return err + } commitPayload := signature.ConstructCommitPayload(consensus.Blockchain().Config(), blockObj.Epoch(), blockObj.Hash(), blockObj.NumberU64(), blockObj.Header().ViewID().Uint64()) diff --git a/consensus/validator.go b/consensus/validator.go index 874edb0ef8..297c04f44d 100644 --- a/consensus/validator.go +++ b/consensus/validator.go @@ -40,16 +40,7 @@ func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { } return } - consensus.StartFinalityCount() - - consensus.getLogger().Info(). - Uint64("MsgViewID", recvMsg.ViewID). - Uint64("MsgBlockNum", recvMsg.BlockNum). - Msg("[OnAnnounce] Announce message Added") - consensus.fBFTLog.AddVerifiedMessage(recvMsg) - consensus.current.blockHash = recvMsg.BlockHash - // we have already added message and block, skip check viewID - // and send prepare message if is in ViewChanging mode + // Do not cache or sign an announce while view change is in progress. if consensus.isViewChangingMode() { consensus.getLogger().Debug(). Msg("[OnAnnounce] Still in ViewChanging Mode, Exiting !!") @@ -65,19 +56,30 @@ func (consensus *Consensus) onAnnounce(msg *msg_pb.Message) { } return } + // Announce must carry the block. Signing its hash before decoding and fully + // validating the block would allow a safe outer ViewID to mask an unsafe + // ViewID in the signed block header. + if len(recvMsg.Block) == 0 { + consensus.getLogger().Warn(). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Announce has no block payload") + return + } + if _, err := consensus.validateNewBlock(recvMsg); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Block validation failed before PREPARE") + return + } + + consensus.StartFinalityCount() + consensus.current.blockHash = recvMsg.BlockHash + consensus.getLogger().Info(). + Uint64("MsgViewID", recvMsg.ViewID). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Msg("[OnAnnounce] Validated announce added") consensus.prepare() consensus.switchPhase("Announce", FBFTPrepare) - - if len(recvMsg.Block) > 0 { - go func() { - // Best effort check, no need to error out. - _, err := consensus.ValidateNewBlock(recvMsg) - if err == nil { - consensus.GetLogger().Info(). - Msgf("[Announce] Block verified %d", recvMsg.BlockNum) - } - }() - } } func (consensus *Consensus) ValidateNewBlock(recvMsg *FBFTMessage) (*types.Block, error) { @@ -101,6 +103,9 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block } blockObj = &blockObj2 } + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blockObj, recvMsg.ViewID); err != nil { + return nil, err + } consensus.getLogger().Info(). Msg("[validateNewBlock] Block Already verified") return blockObj, nil @@ -115,14 +120,20 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block return nil, errors.New("Failed parsing new block") } - consensus.fBFTLog.AddBlock(&blockObj) - + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(&blockObj, recvMsg.ViewID); err != nil { + return nil, err + } // let this handle it own logs if !consensus.newBlockSanityChecks(&blockObj, recvMsg) { return nil, errors.New("new block failed sanity checks") } + if err := consensus.verifyBlock(&blockObj); err != nil { + consensus.getLogger().Error().Err(err).Msg("[validateNewBlock] Block verification failed") + return nil, errors.Errorf("Block verification failed: %s", err.Error()) + } - // add block field + // Only cache the block and message after all validation has succeeded. + consensus.fBFTLog.AddBlock(&blockObj) blockPayload := make([]byte, len(recvMsg.Block)) copy(blockPayload[:], recvMsg.Block[:]) consensus.current.block = blockPayload @@ -133,11 +144,6 @@ func (consensus *Consensus) validateNewBlock(recvMsg *FBFTMessage) (*types.Block Uint64("MsgBlockNum", recvMsg.BlockNum). Hex("blockHash", recvMsg.BlockHash[:]). Msg("[validateNewBlock] Prepared message and block added") - - if err := consensus.verifyBlock(&blockObj); err != nil { - consensus.getLogger().Error().Err(err).Msg("[validateNewBlock] Block verification failed") - return nil, errors.Errorf("Block verification failed: %s", err.Error()) - } return &blockObj, nil } @@ -168,6 +174,10 @@ func (consensus *Consensus) sendCommitMessages(blockObj *types.Block) { if consensus.isBackup || blockObj == nil { return } + if err := consensus.assertEmergencyRecoveryBlockViewID(blockObj.Header().ViewID().Uint64()); err != nil { + consensus.getLogger().Error().Err(err).Msg("[sendCommitMessages] unsafe recovery ViewID") + return + } priKeys, err := consensus.getPriKeysInCommittee() if err != nil { @@ -241,6 +251,7 @@ func (consensus *Consensus) onPrepared(recvMsg *FBFTMessage) { Uint64("MsgBlockNum", recvMsg.BlockNum). Uint64("MsgViewID", recvMsg.ViewID). Msg("[OnPrepared] failed to verify new block") + return } if consensus.checkViewID(recvMsg) != nil { @@ -301,6 +312,13 @@ func (consensus *Consensus) onCommitted(recvMsg *FBFTMessage) { Uint64("MsgBlockNum", recvMsg.BlockNum). Uint64("MsgViewID", recvMsg.ViewID). Msg("[OnCommitted] Received committed message") + if err := consensus.assertEmergencyRecoveryViewID(recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Uint64("MsgViewID", recvMsg.ViewID). + Msg("[OnCommitted] rejected ViewID below recovery floor") + return + } // Ok to receive committed from last block since it could have more signatures if recvMsg.BlockNum < consensus.BlockNum()-1 { @@ -333,6 +351,13 @@ func (consensus *Consensus) onCommitted(recvMsg *FBFTMessage) { Msg("[OnCommitted] Failed finding a matching block for committed message") return } + if err := consensus.validateEmergencyRecoveryMessageBlockViewID(blockObj, recvMsg.ViewID); err != nil { + consensus.getLogger().Warn().Err(err). + Uint64("MsgBlockNum", recvMsg.BlockNum). + Uint64("MsgViewID", recvMsg.ViewID). + Msg("[OnCommitted] rejected unsafe block ViewID") + return + } sigBytes, bitmap, err := chain.ParseCommitSigAndBitmap(recvMsg.Payload) if err != nil { consensus.getLogger().Error().Err(err). diff --git a/consensus/view_change.go b/consensus/view_change.go index cc0fbd0d49..a2a1629a9b 100644 --- a/consensus/view_change.go +++ b/consensus/view_change.go @@ -1,6 +1,7 @@ package consensus import ( + "math" "math/big" "sync/atomic" "time" @@ -41,8 +42,11 @@ func (pm *State) GetCurBlockViewID() uint64 { // SetCurBlockViewID sets the current view id func (pm *State) SetCurBlockViewID(viewID uint64) uint64 { - atomic.StoreUint64(&pm.blockViewID, viewID) - return viewID + if pm.GetViewIDFloor() == 0 { + atomic.StoreUint64(&pm.blockViewID, viewID) + return viewID + } + return atomicMaxUint64(&pm.blockViewID, pm.clampViewID(viewID)) } // GetViewChangingID return the current view changing id @@ -54,7 +58,15 @@ func (pm *State) GetViewChangingID() uint64 { // SetViewChangingID set the current view changing id // It is meaningful during view change mode func (pm *State) SetViewChangingID(id uint64) { - atomic.StoreUint64(&pm.viewChangingID, id) + if pm.GetViewIDFloor() == 0 { + atomic.StoreUint64(&pm.viewChangingID, id) + return + } + id = pm.clampViewID(id) + if current := pm.GetCurBlockViewID(); id < current { + id = current + } + atomicMaxUint64(&pm.viewChangingID, id) } // GetViewChangeDuraion return the duration of the current view change @@ -66,15 +78,23 @@ func (pm *State) GetViewChangeDuraion() time.Duration { // fallbackNextViewID return the next view ID and duration when there is an exception // to calculate the time-based viewId -func (pm *State) fallbackNextViewID() (uint64, time.Duration) { - diff := int64(pm.GetViewChangingID() + 1 - pm.GetCurBlockViewID()) +func (pm *State) fallbackNextViewID() (uint64, time.Duration, error) { + calculated, err := checkedNextViewID(pm.GetViewChangingID()) + if err != nil { + return 0, 0, err + } + nextViewID, err := pm.nextViewID(calculated) + if err != nil { + return 0, 0, err + } + diff := int64(nextViewID - pm.GetCurBlockViewID()) if diff <= 0 { diff = int64(1) } pm.getLogger().Error(). Int64("diff", diff). Msg("[fallbackNextViewID] use legacy viewID algorithm") - return pm.GetViewChangingID() + 1, time.Duration(diff * diff * int64(viewChangeDuration)) + return nextViewID, time.Duration(diff * diff * int64(viewChangeDuration)), nil } // effectiveViewChangeTimestamp returns the wall time used to advance view ID. @@ -108,12 +128,15 @@ func viewChangeTimestampDiff(curTimestamp, blockTimestamp int64, timestampValida // The view change duration is a fixed duration now to avoid stuck into offline nodes during // the view change. // viewID is only used as the fallback mechansim to determine the nextViewID -func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.ChainConfig) (uint64, time.Duration) { +func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.ChainConfig) (uint64, time.Duration, error) { if curHeader == nil { return pm.fallbackNextViewID() } blockTimestamp := curHeader.Time().Int64() - stuckBlockViewID := curHeader.ViewID().Uint64() + 1 + stuckBlockViewID, err := checkedNextViewID(curHeader.ViewID().Uint64()) + if err != nil { + return 0, 0, err + } curTimestamp := time.Now().Unix() timestampValidation := chainConfig != nil && chainConfig.IsTimestampValidation(curHeader.Epoch()) @@ -125,7 +148,14 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai Msg("[getNextViewID] timestamp of block too high") return pm.fallbackNextViewID() } - nextViewID := diff + stuckBlockViewID + calculated, err := checkedAddViewID(diff, stuckBlockViewID) + if err != nil { + return 0, 0, err + } + nextViewID, err := pm.nextViewID(calculated) + if err != nil { + return 0, 0, err + } pm.getLogger().Info(). Int64("curTimestamp", curTimestamp). @@ -136,7 +166,7 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai Msg("[getNextViewID]") // duration is always the fixed view change duration for synchronous view change - return nextViewID, viewChangeDuration + return nextViewID, viewChangeDuration, nil } // getNextLeaderKey uniquely determine who is the leader for given viewID @@ -144,11 +174,21 @@ func (pm *State) getNextViewID(curHeader *block.Header, chainConfig *params.Chai // the next leader based on the gap of the viewID of the view change and the last // know view id of the block. func (pm *State) getNextLeaderKey(blockchain engine.ChainReader, decider quorum.Decider, viewID uint64, committee *shard.Committee) *bls.PublicKeyWrapper { + viewID = pm.clampViewID(viewID) + if viewID == math.MaxUint64 { + pm.getLogger().Error().Msg("[getNextLeaderKey] exhausted ViewID") + return nil + } gap := 1 cur := pm.GetCurBlockViewID() if viewID > cur { - gap = int(viewID - cur) + delta := viewID - cur + if delta > uint64(^uint(0)>>1) { + pm.getLogger().Error().Msg("[getNextLeaderKey] ViewID gap overflows int") + return nil + } + gap = int(delta) } var lastLeaderPubKey *bls.PublicKeyWrapper var err error @@ -162,8 +202,11 @@ func (pm *State) getNextLeaderKey(blockchain engine.ChainReader, decider quorum. pm.getLogger().Error().Msg("[getNextLeaderKey] Failed to get current header from blockchain") lastLeaderPubKey = pm.getLeaderPubKey() } else { - stuckBlockViewID := curHeader.ViewID().Uint64() + 1 - gap = int(viewID - stuckBlockViewID) + gap, err = checkedLeaderViewGap(viewID, curHeader.ViewID().Uint64()) + if err != nil { + pm.getLogger().Error().Err(err).Msg("[getNextLeaderKey] invalid ViewID gap") + return nil + } // this is the truth of the leader based on blockchain blocks lastLeaderPubKey, err = chain.GetLeaderPubKeyFromCoinbase(blockchain, curHeader) if err != nil || lastLeaderPubKey == nil { @@ -247,7 +290,11 @@ func (consensus *Consensus) startViewChange() { consensus.consensusTimeout[timeoutBootstrap].Stop() consensus.current.SetMode(ViewChanging) curHeader := consensus.Blockchain().CurrentHeader() - nextViewID, duration := consensus.current.getNextViewID(curHeader, consensus.Blockchain().Config()) + nextViewID, duration, err := consensus.current.getNextViewID(curHeader, consensus.Blockchain().Config()) + if err != nil { + consensus.getLogger().Error().Err(err).Msg("[startViewChange] cannot advance ViewID") + return + } consensus.setViewChangingID(nextViewID) epoch := curHeader.Epoch() ss, err := consensus.Blockchain().ReadShardState(epoch) @@ -266,8 +313,12 @@ func (consensus *Consensus) startViewChange() { // aganist the consensus.LeaderPubKey variable. // Ideally, we shall use another variable to keep track of the // leader pubkey in viewchange mode - consensus.setLeaderPubKey( - consensus.current.getNextLeaderKey(consensus.Blockchain(), consensus.decider(), nextViewID, committee)) + nextLeader := consensus.current.getNextLeaderKey(consensus.Blockchain(), consensus.decider(), nextViewID, committee) + if nextLeader == nil { + consensus.getLogger().Error().Msg("[startViewChange] cannot select leader for recovery-safe ViewID") + return + } + consensus.setLeaderPubKey(nextLeader) consensus.getLogger().Warn(). Uint64("nextViewID", nextViewID). @@ -291,7 +342,7 @@ func (consensus *Consensus) startViewChange() { consensus.getBlockNum(), consensus.priKey, members, - consensus.verifyBlock, + consensus.verifyEmergencyRecoveryBlock, ); err != nil { consensus.getLogger().Error().Err(err).Msg("[startViewChange] Init Payload Error") } @@ -303,6 +354,10 @@ func (consensus *Consensus) startViewChange() { continue } msgToSend := consensus.constructViewChangeMessage(&key) + if msgToSend == nil { + consensus.getLogger().Error().Msg("[startViewChange] refused to construct unsafe ViewChange message") + continue + } if err := consensus.msgSender.SendWithRetry( consensus.getBlockNum(), msg_pb.MessageType_VIEWCHANGE, @@ -321,6 +376,9 @@ func (consensus *Consensus) startNewView(viewID uint64, newLeaderPriKey *bls.Pri if !consensus.isViewChangingMode() { return errors.New("not in view changing mode anymore") } + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + return err + } msgToSend := consensus.constructNewViewMessage( viewID, newLeaderPriKey, @@ -414,13 +472,13 @@ func (consensus *Consensus) onViewChange(recvMsg *FBFTMessage) { recvMsg.BlockNum, consensus.priKey, members, - consensus.verifyBlock, + consensus.verifyEmergencyRecoveryBlock, ); err != nil { consensus.getLogger().Error().Err(err).Msg("[onViewChange] Init Payload Error") return } - err = consensus.vc.ProcessViewChangeMsg(consensus.fBFTLog, consensus.decider(), recvMsg, consensus.verifyBlock) + err = consensus.vc.ProcessViewChangeMsg(consensus.fBFTLog, consensus.decider(), recvMsg, consensus.verifyEmergencyRecoveryBlock) if err != nil { consensus.getLogger().Error().Err(err). Uint64("viewID", recvMsg.ViewID). @@ -484,12 +542,19 @@ func (consensus *Consensus) onNewView(recvMsg *FBFTMessage) { return } senderKey := recvMsg.SenderPubkeys[0] + if err := consensus.validateExpectedNewViewLeader(senderKey, recvMsg.ViewID); err != nil { + consensus.getLogger().Warn(). + Err(err). + Str("sender", senderKey.Bytes.Hex()). + Msg("[onNewView] sender is not the selected leader") + return + } if !consensus.onNewViewSanityCheck(recvMsg) { return } - preparedBlock, err := consensus.vc.VerifyNewViewMsg(recvMsg, consensus.verifyBlock) + preparedBlock, err := consensus.vc.VerifyNewViewMsg(recvMsg, consensus.verifyEmergencyRecoveryBlock) if err != nil { consensus.getLogger().Warn().Err(err).Msg("[onNewView] Verify New View Msg Failed") return diff --git a/consensus/view_change_msg.go b/consensus/view_change_msg.go index 1ba9c76063..183abfc66a 100644 --- a/consensus/view_change_msg.go +++ b/consensus/view_change_msg.go @@ -10,6 +10,7 @@ import ( bls_core "github.com/harmony-one/bls/ffi/go/bls" "github.com/harmony-one/harmony/api/proto" msg_pb "github.com/harmony-one/harmony/api/proto/message" + coretypes "github.com/harmony-one/harmony/core/types" bls_cosi "github.com/harmony-one/harmony/crypto/bls" "github.com/harmony-one/harmony/multibls" @@ -18,6 +19,11 @@ import ( // construct the view change message func (consensus *Consensus) constructViewChangeMessage(priKey *bls.PrivateKeyWrapper) []byte { + if err := consensus.assertEmergencyRecoveryViewID(consensus.getViewChangingID()); err != nil { + consensus.getLogger().Error().Err(err). + Msg("[constructViewChangeMessage] unsafe recovery ViewID") + return nil + } message := &msg_pb.Message{ ServiceType: msg_pb.ServiceType_CONSENSUS, Type: msg_pb.MessageType_VIEWCHANGE, @@ -45,7 +51,7 @@ func (consensus *Consensus) constructViewChangeMessage(priKey *bls.PrivateKeyWra Interface("preparedMsg", preparedMsg). Msg("[constructViewChangeMessage] found prepared msg") if block != nil { - if err := consensus.verifyBlock(block); err == nil { + if err := consensus.verifyEmergencyRecoveryBlock(block); err == nil { tmpEncoded, err := rlp.EncodeToBytes(block) if err != nil { consensus.getLogger().Err(err).Msg("[constructViewChangeMessage] Failed encoding block") @@ -101,6 +107,11 @@ func (consensus *Consensus) constructViewChangeMessage(priKey *bls.PrivateKeyWra // new leader construct newview message func (consensus *Consensus) constructNewViewMessage(viewID uint64, priKey *bls.PrivateKeyWrapper) []byte { + if err := consensus.assertEmergencyRecoveryViewID(viewID); err != nil { + consensus.getLogger().Error().Err(err). + Msg("[constructNewViewMessage] unsafe recovery ViewID") + return nil + } message := &msg_pb.Message{ ServiceType: msg_pb.ServiceType_CONSENSUS, Type: msg_pb.MessageType_NEWVIEW, @@ -116,6 +127,17 @@ func (consensus *Consensus) constructNewViewMessage(viewID uint64, priKey *bls.P vcMsg := message.GetViewchange() vcMsg.Payload, vcMsg.PreparedBlock = consensus.vc.GetPreparedBlock(consensus.fBFTLog) + if consensus.current.GetViewIDFloor() > 0 && len(vcMsg.PreparedBlock) > 0 { + var block coretypes.Block + if err := rlp.DecodeBytes(vcMsg.PreparedBlock, &block); err != nil { + consensus.getLogger().Error().Err(err).Msg("[constructNewViewMessage] invalid prepared block") + return nil + } + if err := consensus.verifyEmergencyRecoveryBlock(&block); err != nil { + consensus.getLogger().Error().Err(err).Msg("[constructNewViewMessage] unsafe prepared block") + return nil + } + } vcMsg.M2Aggsigs, vcMsg.M2Bitmap = consensus.vc.GetM2Bitmap(viewID) vcMsg.M3Aggsigs, vcMsg.M3Bitmap = consensus.vc.GetM3Bitmap(viewID) if vcMsg.M3Bitmap == nil || vcMsg.M3Aggsigs == nil { diff --git a/internal/params/emergency_recovery.go b/internal/params/emergency_recovery.go new file mode 100644 index 0000000000..ace77d132b --- /dev/null +++ b/internal/params/emergency_recovery.go @@ -0,0 +1,15 @@ +package params + +const ( + // EmergencyRecoveryShard0RetainedBlock is the last canonical shard-0 block + // retained by the emergency recovery release. + EmergencyRecoveryShard0RetainedBlock uint64 = 92_730_034 + + // EmergencyRecoveryShard1RetainedBlock is the last canonical shard-1 block + // retained by the emergency recovery release. + EmergencyRecoveryShard1RetainedBlock uint64 = 94_978_278 + + // EmergencyRecoveryViewIDFloor is the recovery release's signed activation + // floor for mainnet shards 0 and 1. + EmergencyRecoveryViewIDFloor uint64 = 1_000_000_000 +)