Optional slashing protection for remote keys (#4981)

* Optional slashing protection for remote keys

* Merge remote-tracking branch 'origin/unstable' into disable-slashing-protection-web3signer

* Start writing tests

* Merge remote-tracking branch 'origin/unstable' into disable-slashing-protection-web3signer

* Merge remote-tracking branch 'michael/disable-slashing-protection-web3signer' into disable-slashing-protection-web3signer

* Make half-written tests compile

* Make tests work

* Update help text

* Update book CLI text

* Merge remote-tracking branch 'origin/unstable' into disable-slashing-protection-web3signer

* More logging & CLI tests

* CLI tweaks
This commit is contained in:
Michael Sproul 2024-02-06 12:30:31 +11:00 committed by GitHub
parent 795c5778e1
commit 7bec3f9b59
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 463 additions and 163 deletions

View File

@ -21,6 +21,11 @@ FLAGS:
DEPRECATED. Use --broadcast. By default, Lighthouse publishes attestation, sync committee subscriptions and
proposer preparation messages to all beacon nodes provided in the `--beacon-nodes flag`. This option changes
that behaviour such that these api calls only go out to the first available and synced beacon node
--disable-slashing-protection-web3signer
Disable Lighthouse's slashing protection for all web3signer keys. This can reduce the I/O burden on the VC
but is only safe if slashing protection is enabled on the remote signer and is implemented correctly. DO NOT
ENABLE THIS FLAG UNLESS YOU ARE CERTAIN THAT SLASHING PROTECTION IS ENABLED ON THE REMOTE SIGNER. YOU WILL
GET SLASHED IF YOU USE THIS FLAG WITHOUT ENABLING WEB3SIGNER'S SLASHING PROTECTION.
--enable-doppelganger-protection
If this flag is set, Lighthouse will delay startup for three epochs and monitor for messages on the network
by any of the validators managed by this client. This will result in three (possibly four) epochs worth of

View File

@ -636,3 +636,20 @@ fn validator_registration_batch_size_zero_value() {
.flag("validator-registration-batch-size", Some("0"))
.run();
}
#[test]
fn validator_disable_web3_signer_slashing_protection_default() {
CommandLineTest::new().run().with_config(|config| {
assert!(config.enable_web3signer_slashing_protection);
});
}
#[test]
fn validator_disable_web3_signer_slashing_protection() {
CommandLineTest::new()
.flag("disable-slashing-protection-web3signer", None)
.run()
.with_config(|config| {
assert!(!config.enable_web3signer_slashing_protection);
});
}

View File

@ -45,7 +45,7 @@ mod tests {
initialized_validators::{
load_pem_certificate, load_pkcs12_identity, InitializedValidators,
},
validator_store::ValidatorStore,
validator_store::{Error as ValidatorStoreError, ValidatorStore},
SlashingDatabase, SLASHING_PROTECTION_FILENAME,
};
@ -157,6 +157,18 @@ mod tests {
}
}
#[derive(Debug, Clone, Copy)]
struct SlashingProtectionConfig {
/// Whether to enable slashing protection for web3signer keys locally within Lighthouse.
local: bool,
}
impl Default for SlashingProtectionConfig {
fn default() -> Self {
SlashingProtectionConfig { local: true }
}
}
impl Web3SignerRig {
pub async fn new(network: &str, listen_address: &str, listen_port: u16) -> Self {
GET_WEB3SIGNER_BIN
@ -231,6 +243,8 @@ mod tests {
))
.arg("eth2")
.arg(format!("--network={}", network))
// Can't *easily* test `--slashing-protection-enabled=true` because web3signer
// requires a Postgres instance.
.arg("--slashing-protection-enabled=false")
.stdout(stdio())
.stderr(stdio())
@ -294,10 +308,16 @@ mod tests {
_validator_dir: TempDir,
runtime: Arc<tokio::runtime::Runtime>,
_runtime_shutdown: exit_future::Signal,
using_web3signer: bool,
}
impl ValidatorStoreRig {
pub async fn new(validator_definitions: Vec<ValidatorDefinition>, spec: ChainSpec) -> Self {
pub async fn new(
validator_definitions: Vec<ValidatorDefinition>,
slashing_protection_config: SlashingProtectionConfig,
using_web3signer: bool,
spec: ChainSpec,
) -> Self {
let log = environment::null_logger().unwrap();
let validator_dir = TempDir::new().unwrap();
@ -333,6 +353,10 @@ mod tests {
let slot_clock =
TestingSlotClock::new(Slot::new(0), Duration::from_secs(0), Duration::from_secs(1));
let config = validator_client::Config {
enable_web3signer_slashing_protection: slashing_protection_config.local,
..Default::default()
};
let validator_store = ValidatorStore::<_, E>::new(
initialized_validators,
@ -351,6 +375,7 @@ mod tests {
_validator_dir: validator_dir,
runtime,
_runtime_shutdown: runtime_shutdown,
using_web3signer,
}
}
@ -379,7 +404,12 @@ mod tests {
}
impl TestingRig {
pub async fn new(network: &str, spec: ChainSpec, listen_port: u16) -> Self {
pub async fn new(
network: &str,
slashing_protection_config: SlashingProtectionConfig,
spec: ChainSpec,
listen_port: u16,
) -> Self {
let signer_rig =
Web3SignerRig::new(network, WEB3SIGNER_LISTEN_ADDRESS, listen_port).await;
let validator_pubkey = signer_rig.keypair.pk.clone();
@ -401,7 +431,13 @@ mod tests {
voting_keystore_password: Some(KEYSTORE_PASSWORD.to_string().into()),
},
};
ValidatorStoreRig::new(vec![validator_definition], spec.clone()).await
ValidatorStoreRig::new(
vec![validator_definition],
slashing_protection_config,
false,
spec.clone(),
)
.await
};
let remote_signer_validator_store = {
@ -423,7 +459,13 @@ mod tests {
client_identity_password: Some(client_identity_password()),
}),
};
ValidatorStoreRig::new(vec![validator_definition], spec).await
ValidatorStoreRig::new(
vec![validator_definition],
slashing_protection_config,
true,
spec,
)
.await
};
Self {
@ -466,6 +508,36 @@ mod tests {
assert!(prev_signature.is_some(), "sanity check");
self
}
/// Assert that a slashable message fails to be signed locally and is either signed or not
/// by the web3signer rig depending on the value of `web3signer_should_sign`.
pub async fn assert_slashable_message_should_sign<F, R>(
self,
case_name: &str,
generate_sig: F,
web3signer_should_sign: bool,
) -> Self
where
F: Fn(PublicKeyBytes, Arc<ValidatorStore<TestingSlotClock, E>>) -> R,
R: Future<Output = Result<(), ValidatorStoreError>>,
{
for validator_rig in &self.validator_rigs {
let result =
generate_sig(self.validator_pubkey, validator_rig.validator_store.clone())
.await;
if !validator_rig.using_web3signer || !web3signer_should_sign {
let err = result.unwrap_err();
assert!(
matches!(err, ValidatorStoreError::Slashable(_)),
"should not sign slashable {case_name}"
);
} else {
assert_eq!(result, Ok(()), "should sign slashable {case_name}");
}
}
self
}
}
/// Get a generic, arbitrary attestation for signing.
@ -504,7 +576,12 @@ mod tests {
let network_config = Eth2NetworkConfig::constant(network).unwrap().unwrap();
let spec = &network_config.chain_spec::<E>().unwrap();
TestingRig::new(network, spec.clone(), listen_port)
TestingRig::new(
network,
SlashingProtectionConfig::default(),
spec.clone(),
listen_port,
)
.await
.assert_signatures_match("randao_reveal", |pubkey, validator_store| async move {
validator_store
@ -573,7 +650,12 @@ mod tests {
.unwrap()
.start_slot(E::slots_per_epoch());
TestingRig::new(network, spec.clone(), listen_port)
TestingRig::new(
network,
SlashingProtectionConfig::default(),
spec.clone(),
listen_port,
)
.await
.assert_signatures_match(
"beacon_block_altair",
@ -591,11 +673,7 @@ mod tests {
"sync_selection_proof",
|pubkey, validator_store| async move {
validator_store
.produce_sync_selection_proof(
&pubkey,
altair_fork_slot,
SyncSubnetId::from(0),
)
.produce_sync_selection_proof(&pubkey, altair_fork_slot, SyncSubnetId::from(0))
.await
.unwrap()
},
@ -605,12 +683,7 @@ mod tests {
"sync_committee_signature",
|pubkey, validator_store| async move {
validator_store
.produce_sync_committee_signature(
altair_fork_slot,
Hash256::zero(),
0,
&pubkey,
)
.produce_sync_committee_signature(altair_fork_slot, Hash256::zero(), 0, &pubkey)
.await
.unwrap()
},
@ -660,7 +733,12 @@ mod tests {
.unwrap()
.start_slot(E::slots_per_epoch());
TestingRig::new(network, spec.clone(), listen_port)
TestingRig::new(
network,
SlashingProtectionConfig::default(),
spec.clone(),
listen_port,
)
.await
.assert_signatures_match("beacon_block_merge", |pubkey, validator_store| async move {
let mut merge_block = BeaconBlockMerge::empty(spec);
@ -673,6 +751,138 @@ mod tests {
.await;
}
async fn test_lighthouse_slashing_protection(
slashing_protection_config: SlashingProtectionConfig,
listen_port: u16,
) {
// Run these tests on mainnet.
let network = "mainnet";
let network_config = Eth2NetworkConfig::constant(network).unwrap().unwrap();
let spec = &network_config.chain_spec::<E>().unwrap();
let merge_fork_slot = spec
.bellatrix_fork_epoch
.unwrap()
.start_slot(E::slots_per_epoch());
// The slashable message should only be signed by the web3signer validator if slashing
// protection is disabled in Lighthouse.
let slashable_message_should_sign = !slashing_protection_config.local;
let first_attestation = || {
let mut attestation = get_attestation();
attestation.data.source.epoch = Epoch::new(1);
attestation.data.target.epoch = Epoch::new(4);
attestation
};
let double_vote_attestation = || {
let mut attestation = first_attestation();
attestation.data.beacon_block_root = Hash256::from_low_u64_be(1);
attestation
};
let surrounding_attestation = || {
let mut attestation = first_attestation();
attestation.data.source.epoch = Epoch::new(0);
attestation.data.target.epoch = Epoch::new(5);
attestation
};
let surrounded_attestation = || {
let mut attestation = first_attestation();
attestation.data.source.epoch = Epoch::new(2);
attestation.data.target.epoch = Epoch::new(3);
attestation
};
let first_block = || {
let mut merge_block = BeaconBlockMerge::empty(spec);
merge_block.slot = merge_fork_slot;
BeaconBlock::Merge(merge_block)
};
let double_vote_block = || {
let mut block = first_block();
*block.state_root_mut() = Hash256::repeat_byte(0xff);
block
};
let current_epoch = Epoch::new(5);
TestingRig::new(
network,
slashing_protection_config,
spec.clone(),
listen_port,
)
.await
.assert_signatures_match("first_attestation", |pubkey, validator_store| async move {
let mut attestation = first_attestation();
validator_store
.sign_attestation(pubkey, 0, &mut attestation, current_epoch)
.await
.unwrap();
attestation
})
.await
.assert_slashable_message_should_sign(
"double_vote_attestation",
move |pubkey, validator_store| async move {
let mut attestation = double_vote_attestation();
validator_store
.sign_attestation(pubkey, 0, &mut attestation, current_epoch)
.await
},
slashable_message_should_sign,
)
.await
.assert_slashable_message_should_sign(
"surrounding_attestation",
move |pubkey, validator_store| async move {
let mut attestation = surrounding_attestation();
validator_store
.sign_attestation(pubkey, 0, &mut attestation, current_epoch)
.await
},
slashable_message_should_sign,
)
.await
.assert_slashable_message_should_sign(
"surrounded_attestation",
move |pubkey, validator_store| async move {
let mut attestation = surrounded_attestation();
validator_store
.sign_attestation(pubkey, 0, &mut attestation, current_epoch)
.await
},
slashable_message_should_sign,
)
.await
.assert_signatures_match("first_block", |pubkey, validator_store| async move {
let block = first_block();
let slot = block.slot();
validator_store
.sign_block(pubkey, block, slot)
.await
.unwrap()
})
.await
.assert_slashable_message_should_sign(
"double_vote_block",
move |pubkey, validator_store| async move {
let block = double_vote_block();
let slot = block.slot();
validator_store
.sign_block(pubkey, block, slot)
.await
.map(|_| ())
},
slashable_message_should_sign,
)
.await;
}
#[tokio::test]
async fn mainnet_base_types() {
test_base_types("mainnet", 4242).await
@ -707,4 +917,14 @@ mod tests {
async fn sepolia_merge_types() {
test_merge_types("sepolia", 4252).await
}
#[tokio::test]
async fn slashing_protection_disabled_locally() {
test_lighthouse_slashing_protection(SlashingProtectionConfig { local: false }, 4253).await
}
#[tokio::test]
async fn slashing_protection_enabled_locally() {
test_lighthouse_slashing_protection(SlashingProtectionConfig { local: true }, 4254).await
}
}

View File

@ -367,6 +367,17 @@ pub fn cli_app<'a, 'b>() -> App<'a, 'b> {
constructed by builders, regardless of payload value.")
.takes_value(false),
)
.arg(
Arg::with_name("disable-slashing-protection-web3signer")
.long("disable-slashing-protection-web3signer")
.help("Disable Lighthouse's slashing protection for all web3signer keys. This can \
reduce the I/O burden on the VC but is only safe if slashing protection \
is enabled on the remote signer and is implemented correctly. DO NOT ENABLE \
THIS FLAG UNLESS YOU ARE CERTAIN THAT SLASHING PROTECTION IS ENABLED ON \
THE REMOTE SIGNER. YOU WILL GET SLASHED IF YOU USE THIS FLAG WITHOUT \
ENABLING WEB3SIGNER'S SLASHING PROTECTION.")
.takes_value(false)
)
/*
* Experimental/development options.
*/

View File

@ -76,6 +76,8 @@ pub struct Config {
pub enable_latency_measurement_service: bool,
/// Defines the number of validators per `validator/register_validator` request sent to the BN.
pub validator_registration_batch_size: usize,
/// Enable slashing protection even while using web3signer keys.
pub enable_web3signer_slashing_protection: bool,
/// Enables block production via the block v3 endpoint. This configuration option can be removed post deneb.
pub produce_block_v3: bool,
/// Specifies the boost factor, a percentage multiplier to apply to the builder's payload value.
@ -124,6 +126,7 @@ impl Default for Config {
broadcast_topics: vec![ApiTopic::Subscriptions],
enable_latency_measurement_service: true,
validator_registration_batch_size: 500,
enable_web3signer_slashing_protection: true,
produce_block_v3: false,
builder_boost_factor: None,
prefer_builder_proposals: false,
@ -407,6 +410,19 @@ impl Config {
return Err("validator-registration-batch-size cannot be 0".to_string());
}
config.enable_web3signer_slashing_protection =
if cli_args.is_present("disable-slashing-protection-web3signer") {
warn!(
log,
"Slashing protection for remote keys disabled";
"info" => "ensure slashing protection on web3signer is enabled or you WILL \
get slashed"
);
false
} else {
true
};
Ok(config)
}
}

View File

@ -117,6 +117,20 @@ impl SigningContext {
}
impl SigningMethod {
/// Return whether this signing method requires local slashing protection.
pub fn requires_local_slashing_protection(
&self,
enable_web3signer_slashing_protection: bool,
) -> bool {
match self {
// Slashing protection is ALWAYS required for local keys. DO NOT TURN THIS OFF.
SigningMethod::LocalKeystore { .. } => true,
// Slashing protection is only required for remote signer keys when the configuration
// dictates that it is desired.
SigningMethod::Web3Signer { .. } => enable_web3signer_slashing_protection,
}
}
/// Return the signature of `signable_message`, with respect to the `signing_context`.
pub async fn get_signature<T: EthSpec, Payload: AbstractExecPayload<T>>(
&self,

View File

@ -97,6 +97,7 @@ pub struct ValidatorStore<T, E: EthSpec> {
fee_recipient_process: Option<Address>,
gas_limit: Option<u64>,
builder_proposals: bool,
enable_web3signer_slashing_protection: bool,
produce_block_v3: bool,
prefer_builder_proposals: bool,
builder_boost_factor: Option<u64>,
@ -131,6 +132,7 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore<T, E> {
fee_recipient_process: config.fee_recipient,
gas_limit: config.gas_limit,
builder_proposals: config.builder_proposals,
enable_web3signer_slashing_protection: config.enable_web3signer_slashing_protection,
produce_block_v3: config.produce_block_v3,
prefer_builder_proposals: config.prefer_builder_proposals,
builder_boost_factor: config.builder_boost_factor,
@ -604,19 +606,26 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore<T, E> {
let signing_context = self.signing_context(Domain::BeaconProposer, signing_epoch);
let domain_hash = signing_context.domain_hash(&self.spec);
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
// Check for slashing conditions.
let slashing_status = self.slashing_protection.check_and_insert_block_proposal(
let slashing_status = if signing_method
.requires_local_slashing_protection(self.enable_web3signer_slashing_protection)
{
self.slashing_protection.check_and_insert_block_proposal(
&validator_pubkey,
&block.block_header(),
domain_hash,
);
)
} else {
Ok(Safe::Valid)
};
match slashing_status {
// We can safely sign this block without slashing.
Ok(Safe::Valid) => {
metrics::inc_counter_vec(&metrics::SIGNED_BLOCKS_TOTAL, &[metrics::SUCCESS]);
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
let signature = signing_method
.get_signature::<E, Payload>(
SignableMessage::BeaconBlock(&block),
@ -672,20 +681,28 @@ impl<T: SlotClock + 'static, E: EthSpec> ValidatorStore<T, E> {
});
}
// Get the signing method and check doppelganger protection.
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
// Checking for slashing conditions.
let signing_epoch = attestation.data.target.epoch;
let signing_context = self.signing_context(Domain::BeaconAttester, signing_epoch);
let domain_hash = signing_context.domain_hash(&self.spec);
let slashing_status = self.slashing_protection.check_and_insert_attestation(
let slashing_status = if signing_method
.requires_local_slashing_protection(self.enable_web3signer_slashing_protection)
{
self.slashing_protection.check_and_insert_attestation(
&validator_pubkey,
&attestation.data,
domain_hash,
);
)
} else {
Ok(Safe::Valid)
};
match slashing_status {
// We can safely sign this attestation.
Ok(Safe::Valid) => {
let signing_method = self.doppelganger_checked_signing_method(validator_pubkey)?;
let signature = signing_method
.get_signature::<E, BlindedPayload<E>>(
SignableMessage::AttestationData(&attestation.data),