Skip to content
This repository has been archived by the owner on Nov 15, 2023. It is now read-only.

BEEFY: add support for slashing validators signing forking commitments #14744

Draft
wants to merge 44 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ee6c180
sc-consensus-beefy: add BEEFY fisherman to gossip network
acatangiu Jul 5, 2023
ce03ec8
sp-consensus-beefy: add invalid fork vote proof and equivalent BeefyApi
acatangiu Jul 5, 2023
e95e316
pallet-beefy: add stubs for reporting invalid fork votes
acatangiu Jul 5, 2023
6651b32
sc-consensus-beefy: fisherman reports invalid votes and justifications
acatangiu Jul 5, 2023
7f988ef
don't check GRANDPA finality
Lederstrumpf Jul 20, 2023
2ab4f5b
change primitive: vote -> commitment
Lederstrumpf Jul 20, 2023
06260f0
add check_signed_commitment/report_invalid_payload
Lederstrumpf Jul 27, 2023
0642a81
update comments
Lederstrumpf Aug 2, 2023
c6c36e3
add dummy for report of invalid fork commitments
Lederstrumpf Aug 2, 2023
6078b41
account for #14471
Lederstrumpf Aug 1, 2023
5da5631
account for #14373
Lederstrumpf Aug 7, 2023
98db7cf
EquivocationOffence.{offender->offenders}
Lederstrumpf Aug 7, 2023
1a45cbe
EquivocationProof->VoteEquivocationProof
Lederstrumpf Aug 7, 2023
4bd78eb
Invalid{""->Vote}EquivocationProof
Lederstrumpf Aug 7, 2023
f4496c9
check_{""->vote}_equivocation_proof
Lederstrumpf Aug 7, 2023
61f9a45
InvalidForkCommitmentProof->ForkEquivocationProof
Lederstrumpf Aug 7, 2023
53a1a88
renames across submit_report_...
Lederstrumpf Aug 7, 2023
7e0ec77
convert EquivocationEvidenceFor to enum (minimal)
Lederstrumpf Aug 7, 2023
e8b97b3
handle ForkEquivocationProof enum variant
Lederstrumpf Aug 7, 2023
d5ceb23
reduce find_mmr_root_digest trait constraint
Lederstrumpf Aug 8, 2023
3b274fa
fix fork equiv. call interfaces (vecs of sigs)
Lederstrumpf Aug 8, 2023
8701d7f
check proof's payload against correct header's
Lederstrumpf Aug 8, 2023
7c2cdfd
rm superfluous report_fork_equiv.correct_header
Lederstrumpf Aug 8, 2023
98eb692
remove duplic. in check_{signed_commitment, proof}
Lederstrumpf Aug 8, 2023
91ce777
update outdated comments
Lederstrumpf Aug 8, 2023
3c3fc1c
remove report_invalid_payload
Lederstrumpf Aug 8, 2023
f2a9745
.+report.+{""->_vote}_equivocations
Lederstrumpf Aug 8, 2023
faea8d9
move correct_header hash check into primitives
Lederstrumpf Aug 9, 2023
c3df631
create_beefy_worker: only push block if at genesis
Lederstrumpf Aug 9, 2023
f1f2bb0
create_beefy_worker: opt. instantiate with TestApi
Lederstrumpf Aug 9, 2023
b0ed4ae
push to reported_fork_equivocations
Lederstrumpf Aug 9, 2023
113076c
add generate_fork_equivocation_proof_vote to tests
Lederstrumpf Aug 9, 2023
772e3b8
test: Alice snitches on Bob's vote equivocation
Lederstrumpf Aug 9, 2023
7d21f52
store reference to key_store in fisherman
Lederstrumpf Aug 10, 2023
6646665
test: Alice doesn't snitch *own* vote equivocation
Lederstrumpf Aug 10, 2023
c97c963
un-stub submit_unsigned_fork_equivocation_report
Lederstrumpf Aug 10, 2023
e4a39b2
Merge remote-tracking branch 'upstream/master' into rhmb/beefy-slashi…
Lederstrumpf Aug 17, 2023
10540ec
Alice reports Bob & Charlie's signed commitment
Aug 24, 2023
50cd76e
Merge remote-tracking branch 'upstream/master' into rhmb/beefy-slashi…
Aug 24, 2023
809784b
cleanup
Lederstrumpf Aug 24, 2023
37ec6c9
fmt
Lederstrumpf Aug 24, 2023
4c2e0ba
fixup! cleanup
Lederstrumpf Aug 24, 2023
3b655a1
remove superfluous None check
Lederstrumpf Aug 24, 2023
a36a01d
Merge branch 'master' into rhmb/beefy-slashing-fisherman
Lederstrumpf Aug 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
244 changes: 244 additions & 0 deletions client/consensus/beefy/src/communication/fisherman.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
// This file is part of Substrate.

// Copyright (C) Parity Technologies (UK) Ltd.
// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0

// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

use crate::{
error::Error,
justification::BeefyVersionedFinalityProof,
keystore::{BeefyKeystore, BeefySignatureHasher},
LOG_TARGET,
};
use log::debug;
use sc_client_api::Backend;
use sp_api::ProvideRuntimeApi;
use sp_blockchain::HeaderBackend;
use sp_consensus_beefy::{
check_fork_equivocation_proof,
ecdsa_crypto::{AuthorityId, Signature},
BeefyApi, ForkEquivocationProof, Payload, PayloadProvider, SignedCommitment, ValidatorSet,
VoteMessage,
};
use sp_runtime::{
generic::BlockId,
traits::{Block, Header, NumberFor},
};
use std::{marker::PhantomData, sync::Arc};

pub(crate) trait BeefyFisherman<B: Block>: Send + Sync {
/// Check `vote` for contained block against expected payload.
fn check_vote(
&self,
vote: VoteMessage<NumberFor<B>, AuthorityId, Signature>,
) -> Result<(), Error>;

/// Check `signed_commitment` for contained block against expected payload.
fn check_signed_commitment(
&self,
signed_commitment: SignedCommitment<NumberFor<B>, Signature>,
) -> Result<(), Error>;

/// Check `proof` for contained block against expected payload.
fn check_proof(&self, proof: BeefyVersionedFinalityProof<B>) -> Result<(), Error>;
}

/// Helper wrapper used to check gossiped votes for (historical) equivocations,
/// and report any such protocol infringements.
pub(crate) struct Fisherman<B: Block, BE, R, P> {
pub backend: Arc<BE>,
pub runtime: Arc<R>,
pub key_store: Arc<BeefyKeystore>,
pub payload_provider: P,
pub _phantom: PhantomData<B>,
}

impl<B, BE, R, P> Fisherman<B, BE, R, P>
where
B: Block,
BE: Backend<B>,
P: PayloadProvider<B>,
R: ProvideRuntimeApi<B> + Send + Sync,
R::Api: BeefyApi<B, AuthorityId>,
{
fn expected_header_and_payload(
&self,
number: NumberFor<B>,
) -> Result<(B::Header, Payload), Error> {
// This should be un-ambiguous since `number` is finalized.
let hash = self
.backend
.blockchain()
.expect_block_hash_from_id(&BlockId::Number(number))
.map_err(|e| Error::Backend(e.to_string()))?;
let header = self
.backend
.blockchain()
.expect_header(hash)
.map_err(|e| Error::Backend(e.to_string()))?;
self.payload_provider
.payload(&header)
.map(|payload| (header, payload))
.ok_or_else(|| Error::Backend("BEEFY Payload not found".into()))
}

fn active_validator_set_at(
&self,
header: &B::Header,
) -> Result<ValidatorSet<AuthorityId>, Error> {
self.runtime
.runtime_api()
.validator_set(header.hash())
.map_err(Error::RuntimeApi)?
.ok_or_else(|| Error::Backend("could not get BEEFY validator set".into()))
}

pub(crate) fn report_fork_equivocation(
&self,
proof: ForkEquivocationProof<NumberFor<B>, AuthorityId, Signature, B::Header>,
) -> Result<(), Error> {
let validator_set = self.active_validator_set_at(&proof.correct_header)?;
let set_id = validator_set.id();

let expected_header_hash = self
.backend
.blockchain()
.expect_block_hash_from_id(&BlockId::Number(proof.commitment.block_number))
.map_err(|e| Error::Backend(e.to_string()))?;

if proof.commitment.validator_set_id != set_id ||
!check_fork_equivocation_proof::<
NumberFor<B>,
AuthorityId,
BeefySignatureHasher,
B::Header,
>(&proof, &expected_header_hash)
{
debug!(target: LOG_TARGET, "🥩 Skip report for bad invalid fork proof {:?}", proof);
return Ok(())
}

let offender_ids =
proof.signatories.iter().cloned().map(|(id, _sig)| id).collect::<Vec<_>>();
if let Some(local_id) = self.key_store.authority_id(validator_set.validators()) {
if offender_ids.contains(&local_id) {
debug!(target: LOG_TARGET, "🥩 Skip equivocation report for own equivocation");
// TODO: maybe error here instead?
return Ok(())
}
}

let hash = proof.correct_header.hash();
let runtime_api = self.runtime.runtime_api();

// generate key ownership proof at that block
let key_owner_proofs = offender_ids
.iter()
.filter_map(|id| {
match runtime_api.generate_key_ownership_proof(hash, set_id, id.clone()) {
Ok(Some(proof)) => Some(Ok(proof)),
Ok(None) => {
debug!(
target: LOG_TARGET,
"🥩 Invalid fork vote offender not part of the authority set."
);
None
},
Err(e) => Some(Err(Error::RuntimeApi(e))),
}
})
.collect::<Result<_, _>>()?;

// submit invalid fork vote report at **best** block
let best_block_hash = self.backend.blockchain().info().best_hash;
runtime_api
.submit_report_fork_equivocation_unsigned_extrinsic(
best_block_hash,
proof,
key_owner_proofs,
)
.map_err(Error::RuntimeApi)?;

Ok(())
}
}

impl<B, BE, R, P> BeefyFisherman<B> for Fisherman<B, BE, R, P>
where
B: Block,
BE: Backend<B>,
P: PayloadProvider<B>,
R: ProvideRuntimeApi<B> + Send + Sync,
R::Api: BeefyApi<B, AuthorityId>,
{
/// Check `vote` for contained block against expected payload.
fn check_vote(
&self,
vote: VoteMessage<NumberFor<B>, AuthorityId, Signature>,
) -> Result<(), Error> {
let number = vote.commitment.block_number;
let (correct_header, expected_payload) = self.expected_header_and_payload(number)?;
if vote.commitment.payload != expected_payload {
let proof = ForkEquivocationProof {
commitment: vote.commitment,
signatories: vec![(vote.id, vote.signature)],
correct_header: correct_header.clone(),
};
self.report_fork_equivocation(proof)?;
}
Ok(())
}

/// Check `signed_commitment` for contained block against expected payload.
fn check_signed_commitment(
&self,
signed_commitment: SignedCommitment<NumberFor<B>, Signature>,
) -> Result<(), Error> {
let SignedCommitment { commitment, signatures } = signed_commitment;
let number = commitment.block_number;
let (correct_header, expected_payload) = self.expected_header_and_payload(number)?;
if commitment.payload != expected_payload {
let validator_set = self.active_validator_set_at(&correct_header)?;
if signatures.len() != validator_set.validators().len() {
// invalid proof
return Ok(())
}
// report every signer of the bad justification
let signatories = validator_set
.validators()
.iter()
.cloned()
.zip(signatures.into_iter())
.filter_map(|(id, signature)| signature.map(|sig| (id, sig)))
.collect();

let proof = ForkEquivocationProof {
commitment,
signatories,
correct_header: correct_header.clone(),
};
self.report_fork_equivocation(proof)?;
}
Ok(())
}

/// Check `proof` for contained block against expected payload.
fn check_proof(&self, proof: BeefyVersionedFinalityProof<B>) -> Result<(), Error> {
match proof {
BeefyVersionedFinalityProof::<B>::V1(signed_commitment) =>
self.check_signed_commitment(signed_commitment),
}
}
}
54 changes: 41 additions & 13 deletions client/consensus/beefy/src/communication/gossip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use wasm_timer::Instant;
use crate::{
communication::{
benefit, cost,
fisherman::BeefyFisherman,
peers::{KnownPeers, PeerReport},
},
justification::{
Expand Down Expand Up @@ -225,26 +226,29 @@ impl<B: Block> Filter<B> {
/// Allows messages for 'rounds >= last concluded' to flow, everything else gets
/// rejected/expired.
///
/// Messages for active and expired rounds are validated for expected payloads and attempts
/// to create forks before head of GRANDPA are reported.
///
///All messaging is handled in a single BEEFY global topic.
pub(crate) struct GossipValidator<B>
where
B: Block,
{
pub(crate) struct GossipValidator<B: Block, F> {
votes_topic: B::Hash,
justifs_topic: B::Hash,
gossip_filter: RwLock<Filter<B>>,
next_rebroadcast: Mutex<Instant>,
known_peers: Arc<Mutex<KnownPeers<B>>>,
report_sender: TracingUnboundedSender<PeerReport>,
pub(crate) fisherman: F,
}

impl<B> GossipValidator<B>
impl<B, F> GossipValidator<B, F>
where
B: Block,
F: BeefyFisherman<B>,
{
pub(crate) fn new(
known_peers: Arc<Mutex<KnownPeers<B>>>,
) -> (GossipValidator<B>, TracingUnboundedReceiver<PeerReport>) {
fisherman: F,
) -> (GossipValidator<B, F>, TracingUnboundedReceiver<PeerReport>) {
let (tx, rx) = tracing_unbounded("mpsc_beefy_gossip_validator", 10_000);
let val = GossipValidator {
votes_topic: votes_topic::<B>(),
Expand All @@ -253,6 +257,7 @@ where
next_rebroadcast: Mutex::new(Instant::now() + REBROADCAST_AFTER),
known_peers,
report_sender: tx,
fisherman,
};
(val, rx)
}
Expand Down Expand Up @@ -287,9 +292,18 @@ where
let filter = self.gossip_filter.read();

match filter.consider_vote(round, set_id) {
Consider::RejectPast => return Action::Discard(cost::OUTDATED_MESSAGE),
Consider::RejectFuture => return Action::Discard(cost::FUTURE_MESSAGE),
Consider::RejectOutOfScope => return Action::Discard(cost::OUT_OF_SCOPE_MESSAGE),
Consider::RejectPast => {
// We know `vote` is for some past (finalized) block. Have fisherman check
// for equivocations. Best-effort, ignore errors such as state pruned.
let _ = self.fisherman.check_vote(vote);
// TODO: maybe raise cost reputation when seeing votes that are intentional
// spam: votes that trigger fisherman reports, but don't go through either
// because signer is/was not authority or similar reasons.
// The idea is to more quickly disconnect neighbors which are attempting DoS.
return Action::Discard(cost::OUTDATED_MESSAGE)
},
Consider::Accept => {},
}

Expand Down Expand Up @@ -331,9 +345,18 @@ where
let guard = self.gossip_filter.read();
// Verify general usefulness of the justification.
match guard.consider_finality_proof(round, set_id) {
Consider::RejectPast => return Action::Discard(cost::OUTDATED_MESSAGE),
Consider::RejectFuture => return Action::Discard(cost::FUTURE_MESSAGE),
Consider::RejectOutOfScope => return Action::Discard(cost::OUT_OF_SCOPE_MESSAGE),
Consider::RejectPast => {
// We know `proof` is for some past (finalized) block. Have fisherman check
// for equivocations. Best-effort, ignore errors such as state pruned.
let _ = self.fisherman.check_proof(proof);
// TODO: maybe raise cost reputation when seeing votes that are intentional
// spam: votes that trigger fisherman reports, but don't go through either because
// signer is/was not authority or similar reasons.
// The idea is to more quickly disconnect neighbors which are attempting DoS.
return Action::Discard(cost::OUTDATED_MESSAGE)
},
Consider::Accept => {},
}
// Verify justification signatures.
Expand All @@ -359,9 +382,10 @@ where
}
}

impl<B> Validator<B> for GossipValidator<B>
impl<B, F> Validator<B> for GossipValidator<B, F>
where
B: Block,
F: BeefyFisherman<B>,
{
fn peer_disconnected(&self, _context: &mut dyn ValidatorContext<B>, who: &PeerId) {
self.known_peers.lock().remove(who);
Expand Down Expand Up @@ -474,14 +498,15 @@ where
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use crate::keystore::BeefyKeystore;
use crate::{keystore::BeefyKeystore, tests::DummyFisherman};
use sc_network_test::Block;
use sp_application_crypto::key_types::BEEFY as BEEFY_KEY_TYPE;
use sp_consensus_beefy::{
ecdsa_crypto::Signature, known_payloads, Commitment, Keyring, MmrRootHash, Payload,
SignedCommitment, VoteMessage,
};
use sp_keystore::{testing::MemoryKeystore, Keystore};
use std::marker::PhantomData;

#[test]
fn known_votes_insert_remove() {
Expand Down Expand Up @@ -577,8 +602,9 @@ pub(crate) mod tests {
fn should_validate_messages() {
let keys = vec![Keyring::Alice.public()];
let validator_set = ValidatorSet::<AuthorityId>::new(keys.clone(), 0).unwrap();
let fisherman = DummyFisherman { _phantom: PhantomData::<Block> };
let (gv, mut report_stream) =
GossipValidator::<Block>::new(Arc::new(Mutex::new(KnownPeers::new())));
GossipValidator::new(Arc::new(Mutex::new(KnownPeers::new())), fisherman);
let sender = PeerId::random();
let mut context = TestContext;

Expand Down Expand Up @@ -705,7 +731,8 @@ pub(crate) mod tests {
fn messages_allowed_and_expired() {
let keys = vec![Keyring::Alice.public()];
let validator_set = ValidatorSet::<AuthorityId>::new(keys.clone(), 0).unwrap();
let (gv, _) = GossipValidator::<Block>::new(Arc::new(Mutex::new(KnownPeers::new())));
let fisherman = DummyFisherman { _phantom: PhantomData::<Block> };
let (gv, _) = GossipValidator::new(Arc::new(Mutex::new(KnownPeers::new())), fisherman);
gv.update_filter(GossipFilterCfg { start: 0, end: 10, validator_set: &validator_set });
let sender = sc_network::PeerId::random();
let topic = Default::default();
Expand Down Expand Up @@ -782,7 +809,8 @@ pub(crate) mod tests {
fn messages_rebroadcast() {
let keys = vec![Keyring::Alice.public()];
let validator_set = ValidatorSet::<AuthorityId>::new(keys.clone(), 0).unwrap();
let (gv, _) = GossipValidator::<Block>::new(Arc::new(Mutex::new(KnownPeers::new())));
let fisherman = DummyFisherman { _phantom: PhantomData::<Block> };
let (gv, _) = GossipValidator::new(Arc::new(Mutex::new(KnownPeers::new())), fisherman);
gv.update_filter(GossipFilterCfg { start: 0, end: 10, validator_set: &validator_set });
let sender = sc_network::PeerId::random();
let topic = Default::default();
Expand Down