github.com/onflow/flow-go@v0.33.17/network/p2p/scoring/registry.go (about) 1 package scoring 2 3 import ( 4 "fmt" 5 "math" 6 "time" 7 8 "github.com/go-playground/validator/v10" 9 "github.com/libp2p/go-libp2p/core/peer" 10 "github.com/rs/zerolog" 11 "go.uber.org/atomic" 12 13 "github.com/onflow/flow-go/engine/common/worker" 14 "github.com/onflow/flow-go/model/flow" 15 "github.com/onflow/flow-go/module" 16 "github.com/onflow/flow-go/module/component" 17 "github.com/onflow/flow-go/module/irrecoverable" 18 "github.com/onflow/flow-go/module/mempool/queue" 19 "github.com/onflow/flow-go/module/metrics" 20 "github.com/onflow/flow-go/network" 21 "github.com/onflow/flow-go/network/p2p" 22 netcache "github.com/onflow/flow-go/network/p2p/cache" 23 p2pconfig "github.com/onflow/flow-go/network/p2p/config" 24 p2plogging "github.com/onflow/flow-go/network/p2p/logging" 25 p2pmsg "github.com/onflow/flow-go/network/p2p/message" 26 "github.com/onflow/flow-go/utils/logging" 27 ) 28 29 const ( 30 // NotificationSilencedMsg log messages for silenced notifications 31 NotificationSilencedMsg = "ignoring invalid control message notification for peer during silence period" 32 ) 33 34 type SpamRecordInitFunc func() p2p.GossipSubSpamRecord 35 36 // GossipSubAppSpecificScoreRegistry is the registry for the application specific score of peers in the GossipSub protocol. 37 // The application specific score is part of the overall score of a peer, and is used to determine the peer's score based 38 // on its behavior related to the application (Flow protocol). 39 // This registry holds the view of the local peer of the application specific score of other peers in the network based 40 // on what it has observed from the network. 41 // Similar to the GossipSub score, the application specific score is meant to be private to the local peer, and is not 42 // shared with other peers in the network. 43 type GossipSubAppSpecificScoreRegistry struct { 44 component.Component 45 logger zerolog.Logger 46 idProvider module.IdentityProvider 47 48 // spamScoreCache currently only holds the control message misbehaviour penalty (spam related penalty). 49 spamScoreCache p2p.GossipSubSpamRecordCache 50 51 penalty p2pconfig.MisbehaviourPenalties 52 53 validator p2p.SubscriptionValidator 54 55 // scoreTTL is the time to live of the application specific score of a peer; the registry keeps a cached copy of the 56 // application specific score of a peer for this duration. When the duration expires, the application specific score 57 // of the peer is updated asynchronously. As long as the update is in progress, the cached copy of the application 58 // specific score of the peer is used even if it is expired. 59 scoreTTL time.Duration 60 61 // appScoreCache is a cache that stores the application specific score of peers. 62 appScoreCache p2p.GossipSubApplicationSpecificScoreCache 63 64 // appScoreUpdateWorkerPool is the worker pool for handling the application specific score update of peers in a non-blocking way. 65 appScoreUpdateWorkerPool *worker.Pool[peer.ID] 66 67 // silencePeriodDuration duration that the startup silence period will last, during which nodes will not be penalized 68 silencePeriodDuration time.Duration 69 // silencePeriodStartTime time that the silence period begins, this is the time that the registry is started by the node. 70 silencePeriodStartTime time.Time 71 // silencePeriodElapsed atomic bool that stores a bool flag which indicates if the silence period is over or not. 72 silencePeriodElapsed *atomic.Bool 73 74 unknownIdentityPenalty float64 75 minAppSpecificPenalty float64 76 stakedIdentityReward float64 77 invalidSubscriptionPenalty float64 78 } 79 80 // GossipSubAppSpecificScoreRegistryConfig is the configuration for the GossipSubAppSpecificScoreRegistry. 81 // Configurations are the "union of parameters and other components" that are used to compute or build components that compute or maintain the application specific score of peers. 82 type GossipSubAppSpecificScoreRegistryConfig struct { 83 Parameters p2pconfig.AppSpecificScoreParameters `validate:"required"` 84 85 Logger zerolog.Logger `validate:"required"` 86 87 // Validator is the subscription validator used to validate the subscriptions of peers, and determine if a peer is 88 // authorized to subscribe to a topic. 89 Validator p2p.SubscriptionValidator `validate:"required"` 90 91 // Penalty encapsulates the penalty unit for each control message type misbehaviour. 92 Penalty p2pconfig.MisbehaviourPenalties `validate:"required"` 93 94 // IdProvider is the identity provider used to translate peer ids at the networking layer to Flow identifiers (if 95 // an authorized peer is found). 96 IdProvider module.IdentityProvider `validate:"required"` 97 98 // SpamRecordCacheFactory is a factory function that returns a new GossipSubSpamRecordCache. It is used to initialize the spamScoreCache. 99 // The cache is used to store the application specific penalty of peers. 100 SpamRecordCacheFactory func() p2p.GossipSubSpamRecordCache `validate:"required"` 101 102 // AppScoreCacheFactory is a factory function that returns a new GossipSubApplicationSpecificScoreCache. It is used to initialize the appScoreCache. 103 // The cache is used to store the application specific score of peers. 104 AppScoreCacheFactory func() p2p.GossipSubApplicationSpecificScoreCache `validate:"required"` 105 106 HeroCacheMetricsFactory metrics.HeroCacheMetricsFactory `validate:"required"` 107 108 NetworkingType network.NetworkingType `validate:"required"` 109 110 // ScoringRegistryStartupSilenceDuration defines the duration of time, after the node startup, 111 // during which the scoring registry remains inactive before penalizing nodes. 112 ScoringRegistryStartupSilenceDuration time.Duration 113 114 AppSpecificScoreParams p2pconfig.ApplicationSpecificScoreParameters `validate:"required"` 115 } 116 117 // NewGossipSubAppSpecificScoreRegistry returns a new GossipSubAppSpecificScoreRegistry. 118 // Args: 119 // 120 // config: the config for the registry. 121 // 122 // Returns: 123 // 124 // a new GossipSubAppSpecificScoreRegistry. 125 // 126 // error: if the configuration is invalid, an error is returned; any returned error is an irrecoverable error and indicates a bug or misconfiguration. 127 func NewGossipSubAppSpecificScoreRegistry(config *GossipSubAppSpecificScoreRegistryConfig) (*GossipSubAppSpecificScoreRegistry, error) { 128 if err := validator.New().Struct(config); err != nil { 129 return nil, fmt.Errorf("invalid config: %w", err) 130 } 131 132 lg := config.Logger.With().Str("module", "app_score_registry").Logger() 133 store := queue.NewHeroStore(config.Parameters.ScoreUpdateRequestQueueSize, 134 lg.With().Str("component", "app_specific_score_update").Logger(), 135 metrics.GossipSubAppSpecificScoreUpdateQueueMetricFactory(config.HeroCacheMetricsFactory, config.NetworkingType)) 136 137 reg := &GossipSubAppSpecificScoreRegistry{ 138 logger: config.Logger.With().Str("module", "app_score_registry").Logger(), 139 spamScoreCache: config.SpamRecordCacheFactory(), 140 appScoreCache: config.AppScoreCacheFactory(), 141 penalty: config.Penalty, 142 validator: config.Validator, 143 idProvider: config.IdProvider, 144 scoreTTL: config.Parameters.ScoreTTL, 145 silencePeriodDuration: config.ScoringRegistryStartupSilenceDuration, 146 silencePeriodElapsed: atomic.NewBool(false), 147 unknownIdentityPenalty: config.AppSpecificScoreParams.UnknownIdentityPenalty, 148 minAppSpecificPenalty: config.AppSpecificScoreParams.MinAppSpecificPenalty, 149 stakedIdentityReward: config.AppSpecificScoreParams.StakedIdentityReward, 150 invalidSubscriptionPenalty: config.AppSpecificScoreParams.InvalidSubscriptionPenalty, 151 } 152 153 reg.appScoreUpdateWorkerPool = worker.NewWorkerPoolBuilder[peer.ID](lg.With().Str("component", "app_specific_score_update_worker_pool").Logger(), 154 store, 155 reg.processAppSpecificScoreUpdateWork).Build() 156 157 builder := component.NewComponentManagerBuilder() 158 builder.AddWorker(func(ctx irrecoverable.SignalerContext, ready component.ReadyFunc) { 159 reg.logger.Info().Msg("starting subscription validator") 160 reg.validator.Start(ctx) 161 select { 162 case <-ctx.Done(): 163 reg.logger.Warn().Msg("aborting subscription validator startup, context cancelled") 164 case <-reg.validator.Ready(): 165 reg.logger.Info().Msg("subscription validator started") 166 ready() 167 reg.logger.Info().Msg("subscription validator is ready") 168 } 169 <-ctx.Done() 170 reg.logger.Info().Msg("stopping subscription validator") 171 <-reg.validator.Done() 172 reg.logger.Info().Msg("subscription validator stopped") 173 }).AddWorker(func(parent irrecoverable.SignalerContext, ready component.ReadyFunc) { 174 if !reg.silencePeriodStartTime.IsZero() { 175 parent.Throw(fmt.Errorf("gossipsub scoring registry started more than once")) 176 } 177 reg.silencePeriodStartTime = time.Now() 178 ready() 179 }) 180 181 for i := 0; i < config.Parameters.ScoreUpdateWorkerNum; i++ { 182 builder.AddWorker(reg.appScoreUpdateWorkerPool.WorkerLogic()) 183 } 184 185 reg.Component = builder.Build() 186 187 return reg, nil 188 } 189 190 var _ p2p.GossipSubInvCtrlMsgNotifConsumer = (*GossipSubAppSpecificScoreRegistry)(nil) 191 192 // AppSpecificScoreFunc returns the application specific score function that is called by the GossipSub protocol to determine the application specific score of a peer. 193 // The application specific score is part of the overall score of a peer, and is used to determine the peer's score based 194 // This function reads the application specific score of a peer from the cache, and if the penalty is not found in the cache, it computes it. 195 // If the score is not found in the cache, it is computed and added to the cache. 196 // Also if the score is expired, it is computed and added to the cache. 197 // Returns: 198 // - func(peer.ID) float64: the application specific score function. 199 // Implementation must be thread-safe. 200 func (r *GossipSubAppSpecificScoreRegistry) AppSpecificScoreFunc() func(peer.ID) float64 { 201 return func(pid peer.ID) float64 { 202 lg := r.logger.With().Str("remote_peer_id", p2plogging.PeerId(pid)).Logger() 203 204 // during startup silence period avoid penalizing nodes 205 if !r.afterSilencePeriod() { 206 lg.Trace().Msg("returning 0 app specific score penalty for node during silence period") 207 return 0 208 } 209 210 appSpecificScore, lastUpdated, ok := r.appScoreCache.Get(pid) 211 switch { 212 case !ok: 213 // record not found in the cache, or expired; submit a worker to update it. 214 submitted := r.appScoreUpdateWorkerPool.Submit(pid) 215 lg.Trace(). 216 Bool("worker_submitted", submitted). 217 Msg("application specific score not found in cache, submitting worker to update it") 218 return 0 // in the mean time, return 0, which is a neutral score. 219 case time.Since(lastUpdated) > r.scoreTTL: 220 // record found in the cache, but expired; submit a worker to update it. 221 submitted := r.appScoreUpdateWorkerPool.Submit(pid) 222 lg.Trace(). 223 Bool("worker_submitted", submitted). 224 Float64("app_specific_score", appSpecificScore). 225 Dur("score_ttl", r.scoreTTL). 226 Msg("application specific score expired, submitting worker to update it") 227 return appSpecificScore // in the mean time, return the expired score. 228 default: 229 // record found in the cache. 230 r.logger.Trace(). 231 Float64("app_specific_score", appSpecificScore). 232 Msg("application specific score found in cache") 233 return appSpecificScore 234 } 235 } 236 } 237 238 // computeAppSpecificScore computes the application specific score of a peer. 239 // The application specific score is computed based on the spam penalty, staking score, and subscription penalty. 240 // The spam penalty is the penalty applied to the application specific score when a peer conducts a spamming misbehaviour. 241 // The staking score is the reward/penalty applied to the application specific score when a peer is staked/unstaked. 242 // The subscription penalty is the penalty applied to the application specific score when a peer is subscribed to a topic that it is not allowed to subscribe to based on its role. 243 // Args: 244 // - pid: the peer ID of the peer in the GossipSub protocol. 245 // Returns: 246 // - float64: the application specific score of the peer. 247 func (r *GossipSubAppSpecificScoreRegistry) computeAppSpecificScore(pid peer.ID) float64 { 248 appSpecificScore := float64(0) 249 250 lg := r.logger.With().Str("peer_id", p2plogging.PeerId(pid)).Logger() 251 // (1) spam penalty: the penalty is applied to the application specific penalty when a peer conducts a spamming misbehaviour. 252 spamRecord, err, spamRecordExists := r.spamScoreCache.Get(pid) 253 if err != nil { 254 // the error is considered fatal as it means the cache is not working properly. 255 // we should not continue with the execution as it may lead to routing attack vulnerability. 256 r.logger.Fatal().Str("peer_id", p2plogging.PeerId(pid)).Err(err).Msg("could not get application specific penalty for peer") 257 return appSpecificScore // unreachable, but added to avoid proceeding with the execution if log level is changed. 258 } 259 260 if spamRecordExists { 261 lg = lg.With().Float64("spam_penalty", spamRecord.Penalty).Logger() 262 appSpecificScore += spamRecord.Penalty 263 } 264 265 // (2) staking score: for staked peers, a default positive reward is applied only if the peer has no penalty on spamming and subscription. 266 // for unknown peers a negative penalty is applied. 267 stakingScore, flowId, role := r.stakingScore(pid) 268 if stakingScore < 0 { 269 lg = lg.With().Float64("staking_penalty", stakingScore).Logger() 270 // staking penalty is applied right away. 271 appSpecificScore += stakingScore 272 } 273 274 if stakingScore >= 0 { 275 // (3) subscription penalty: the subscription penalty is applied to the application specific penalty when a 276 // peer is subscribed to a topic that it is not allowed to subscribe to based on its role. 277 // Note: subscription penalty can be considered only for staked peers, for non-staked peers, we cannot 278 // determine the role of the peer. 279 subscriptionPenalty := r.subscriptionPenalty(pid, flowId, role) 280 lg = lg.With().Float64("subscription_penalty", subscriptionPenalty).Logger() 281 if subscriptionPenalty < 0 { 282 appSpecificScore += subscriptionPenalty 283 } 284 } 285 286 // (4) staking reward: for staked peers, a default positive reward is applied only if the peer has no penalty on spamming and subscription. 287 if stakingScore > 0 && appSpecificScore == float64(0) { 288 lg = lg.With().Float64("staking_reward", stakingScore).Logger() 289 appSpecificScore += stakingScore 290 } 291 292 lg.Trace(). 293 Float64("total_app_specific_score", appSpecificScore). 294 Msg("application specific score computed") 295 296 return appSpecificScore 297 } 298 299 // processMisbehaviorReport is the worker function that is called by the worker pool to update the application specific score of a peer. 300 // The function is called in a non-blocking way, and the worker pool is used to limit the number of concurrent executions of the function. 301 // Args: 302 // - pid: the peer ID of the peer in the GossipSub protocol. 303 // Returns: 304 // - error: an error if the update failed; any returned error is an irrecoverable error and indicates a bug or misconfiguration. 305 func (r *GossipSubAppSpecificScoreRegistry) processAppSpecificScoreUpdateWork(p peer.ID) error { 306 appSpecificScore := r.computeAppSpecificScore(p) 307 err := r.appScoreCache.AdjustWithInit(p, appSpecificScore, time.Now()) 308 if err != nil { 309 // the error is considered fatal as it means the cache is not working properly. 310 return fmt.Errorf("could not add application specific score %f for peer to cache: %w", appSpecificScore, err) 311 } 312 r.logger.Trace(). 313 Str("remote_peer_id", p2plogging.PeerId(p)). 314 Float64("app_specific_score", appSpecificScore). 315 Msg("application specific score computed and cache updated") 316 return nil 317 } 318 319 func (r *GossipSubAppSpecificScoreRegistry) stakingScore(pid peer.ID) (float64, flow.Identifier, flow.Role) { 320 lg := r.logger.With().Str("peer_id", p2plogging.PeerId(pid)).Logger() 321 322 // checks if peer has a valid Flow protocol identity. 323 flowId, err := HasValidFlowIdentity(r.idProvider, pid) 324 if err != nil { 325 lg.Error(). 326 Err(err). 327 Bool(logging.KeySuspicious, true). 328 Msg("invalid peer identity, penalizing peer") 329 return r.unknownIdentityPenalty, flow.Identifier{}, 0 330 } 331 332 lg = lg.With(). 333 Hex("flow_id", logging.ID(flowId.NodeID)). 334 Str("role", flowId.Role.String()). 335 Logger() 336 337 // checks if peer is an access node, and if so, pushes it to the 338 // edges of the network by giving the minimum penalty. 339 if flowId.Role == flow.RoleAccess { 340 lg.Trace(). 341 Msg("pushing access node to edge by penalizing with minimum penalty value") 342 return r.minAppSpecificPenalty, flowId.NodeID, flowId.Role 343 } 344 345 lg.Trace(). 346 Msg("rewarding well-behaved non-access node peer with maximum reward value") 347 348 return r.stakedIdentityReward, flowId.NodeID, flowId.Role 349 } 350 351 func (r *GossipSubAppSpecificScoreRegistry) subscriptionPenalty(pid peer.ID, flowId flow.Identifier, role flow.Role) float64 { 352 // checks if peer has any subscription violation. 353 if err := r.validator.CheckSubscribedToAllowedTopics(pid, role); err != nil { 354 r.logger.Warn(). 355 Err(err). 356 Str("peer_id", p2plogging.PeerId(pid)). 357 Hex("flow_id", logging.ID(flowId)). 358 Bool(logging.KeySuspicious, true). 359 Msg("invalid subscription detected, penalizing peer") 360 return r.invalidSubscriptionPenalty 361 } 362 363 return 0 364 } 365 366 // OnInvalidControlMessageNotification is called when a new invalid control message notification is distributed. 367 // Any error on consuming event must handle internally. 368 // The implementation must be concurrency safe, but can be blocking. 369 func (r *GossipSubAppSpecificScoreRegistry) OnInvalidControlMessageNotification(notification *p2p.InvCtrlMsgNotif) { 370 // we use mutex to ensure the method is concurrency safe. 371 372 lg := r.logger.With(). 373 Err(notification.Error). 374 Str("peer_id", p2plogging.PeerId(notification.PeerID)). 375 Str("misbehavior_type", notification.MsgType.String()).Logger() 376 377 // during startup silence period avoid penalizing nodes, ignore all notifications 378 if !r.afterSilencePeriod() { 379 lg.Trace().Msg("ignoring invalid control message notification for peer during silence period") 380 return 381 } 382 383 record, err := r.spamScoreCache.Adjust(notification.PeerID, func(record p2p.GossipSubSpamRecord) p2p.GossipSubSpamRecord { 384 penalty := 0.0 385 switch notification.MsgType { 386 case p2pmsg.CtrlMsgGraft: 387 penalty += r.penalty.GraftMisbehaviour 388 case p2pmsg.CtrlMsgPrune: 389 penalty += r.penalty.PruneMisbehaviour 390 case p2pmsg.CtrlMsgIHave: 391 penalty += r.penalty.IHaveMisbehaviour 392 case p2pmsg.CtrlMsgIWant: 393 penalty += r.penalty.IWantMisbehaviour 394 case p2pmsg.RpcPublishMessage: 395 penalty += r.penalty.PublishMisbehaviour 396 default: 397 // the error is considered fatal as it means that we have an unsupported misbehaviour type, we should crash the node to prevent routing attack vulnerability. 398 lg.Fatal().Str("misbehavior_type", notification.MsgType.String()).Msg("unknown misbehaviour type") 399 } 400 401 // reduce penalty for cluster prefixed topics allowing nodes that are potentially behind to catch up 402 if notification.TopicType == p2p.CtrlMsgTopicTypeClusterPrefixed { 403 penalty *= r.penalty.ClusterPrefixedReductionFactor 404 } 405 406 record.Penalty += penalty 407 408 return record 409 }) 410 if err != nil { 411 // any returned error from adjust is non-recoverable and fatal, we crash the node. 412 lg.Fatal().Err(err).Msg("could not adjust application specific penalty for peer") 413 } 414 415 lg.Debug(). 416 Float64("spam_record_penalty", record.Penalty). 417 Msg("applied misbehaviour penalty and updated application specific penalty") 418 } 419 420 // afterSilencePeriod returns true if registry silence period is over, false otherwise. 421 func (r *GossipSubAppSpecificScoreRegistry) afterSilencePeriod() bool { 422 if !r.silencePeriodElapsed.Load() { 423 if time.Since(r.silencePeriodStartTime) > r.silencePeriodDuration { 424 r.silencePeriodElapsed.Store(true) 425 return true 426 } 427 return false 428 } 429 return true 430 } 431 432 // DefaultDecayFunction is the default decay function that is used to decay the application specific penalty of a peer. 433 // It is used if no decay function is provided in the configuration. 434 // It decays the application specific penalty of a peer if it is negative. 435 func DefaultDecayFunction(cfg p2pconfig.SpamRecordCacheDecay) netcache.PreprocessorFunc { 436 return func(record p2p.GossipSubSpamRecord, lastUpdated time.Time) (p2p.GossipSubSpamRecord, error) { 437 if record.Penalty >= 0 { 438 // no need to decay the penalty if it is positive, the reason is currently the app specific penalty 439 // is only used to penalize peers. Hence, when there is no reward, there is no need to decay the positive penalty, as 440 // no node can accumulate a positive penalty. 441 return record, nil 442 } 443 444 if record.Penalty > cfg.SkipDecayThreshold { 445 // penalty is negative but greater than the threshold, we set it to 0. 446 record.Penalty = 0 447 record.Decay = cfg.MaximumSpamPenaltyDecayFactor 448 record.LastDecayAdjustment = time.Time{} 449 return record, nil 450 } 451 452 // penalty is negative and below the threshold, we decay it. 453 penalty, err := GeometricDecay(record.Penalty, record.Decay, lastUpdated) 454 if err != nil { 455 return record, fmt.Errorf("could not decay application specific penalty: %w", err) 456 } 457 record.Penalty = penalty 458 459 if record.Penalty <= cfg.PenaltyDecaySlowdownThreshold { 460 if time.Since(record.LastDecayAdjustment) > cfg.PenaltyDecayEvaluationPeriod || record.LastDecayAdjustment.IsZero() { 461 // reduces the decay speed flooring at MinimumSpamRecordDecaySpeed 462 record.Decay = math.Min(record.Decay+cfg.DecayRateReductionFactor, cfg.MinimumSpamPenaltyDecayFactor) 463 record.LastDecayAdjustment = time.Now() 464 } 465 } 466 return record, nil 467 } 468 } 469 470 // InitAppScoreRecordStateFunc returns a callback that initializes the gossipsub spam record state for a peer. 471 // Returns: 472 // - a func that returns a gossipsub spam record with the default decay value and 0 penalty. 473 func InitAppScoreRecordStateFunc(maximumSpamPenaltyDecayFactor float64) func() p2p.GossipSubSpamRecord { 474 return func() p2p.GossipSubSpamRecord { 475 return p2p.GossipSubSpamRecord{ 476 Decay: maximumSpamPenaltyDecayFactor, 477 Penalty: 0, 478 LastDecayAdjustment: time.Now(), 479 } 480 } 481 }