diff --git a/.cargo/audit.toml b/.cargo/audit.toml index f1618d62..85a6c724 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -1,3 +1,2 @@ [advisories] -ignore = [] informational_warnings = [] \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 81fa4447..e5fdb7bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "acropolis_module_block_kes_validator" +version = "0.1.0" +dependencies = [ + "acropolis_common", + "anyhow", + "caryatid_sdk", + "config", + "hex", + "imbl", + "kes-summed-ed25519 0.2.1 (git+https://github.com/txpipe/kes?rev=f69fb357d46f6a18925543d785850059569d7e78)", + "pallas 0.33.0", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "acropolis_module_block_unpacker" version = "0.2.0" @@ -454,6 +471,7 @@ dependencies = [ "acropolis_module_accounts_state", "acropolis_module_address_state", "acropolis_module_assets_state", + "acropolis_module_block_kes_validator", "acropolis_module_block_unpacker", "acropolis_module_block_vrf_validator", "acropolis_module_chain_store", @@ -3384,6 +3402,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kes-summed-ed25519" +version = "0.2.1" +source = "git+https://github.com/txpipe/kes?rev=f69fb357d46f6a18925543d785850059569d7e78#f69fb357d46f6a18925543d785850059569d7e78" +dependencies = [ + "blake2 0.10.6", + "ed25519-dalek", + "rand_core 0.6.4", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "lapin" version = "3.7.2" @@ -3718,7 +3748,7 @@ dependencies = [ "ed25519-dalek", "fixed", "hex", - "kes-summed-ed25519", + "kes-summed-ed25519 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "mithril-build-script", "mithril-stm", "nom 8.0.0", diff --git a/Cargo.toml b/Cargo.toml index 4e8304be..77d4e395 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "modules/chain_store", # Tracks historical information about blocks and TXs "modules/tx_submitter", # Submits TXs to peers "modules/block_vrf_validator", # Validate the VRF calculation in the block header + "modules/block_kes_validator", # Validate KES in the block header # Process builds "processes/omnibus", # All-inclusive omnibus process diff --git a/common/src/validation.rs b/common/src/validation.rs index d0e55735..d92c06bd 100644 --- a/common/src/validation.rs +++ b/common/src/validation.rs @@ -15,8 +15,8 @@ pub enum ValidationError { #[error("VRF failure: {0}")] BadVRF(#[from] VrfValidationError), - #[error("KES failure")] - BadKES, + #[error("KES failure: {0}")] + BadKES(#[from] KesValidationError), #[error("Doubly spent UTXO: {0}")] DoubleSpendUTXO(String), @@ -224,3 +224,106 @@ impl PartialEq for BadVrfProofError { } } } + +/// Reference +/// https://github.com/IntersectMBO/ouroboros-consensus/blob/e3c52b7c583bdb6708fac4fdaa8bf0b9588f5a88/ouroboros-consensus-protocol/src/ouroboros-consensus-protocol/Ouroboros/Consensus/Protocol/Praos.hs#L342 +#[derive(Error, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum KesValidationError { + /// **Cause:** The KES signature on the block header is invalid. + #[error("KES Signature Error: {0}")] + KesSignatureError(#[from] KesSignatureError), + /// **Cause:** The operational certificate is invalid. + #[error("Operational Certificate Error: {0}")] + OperationalCertificateError(#[from] OperationalCertificateError), + /// **Cause:** No OCert counter found for this issuer (not a stake pool or genesis delegate) + #[error("No OCert Counter For Issuer: Pool ID={}", hex::encode(pool_id))] + NoOCertCounter { pool_id: PoolId }, + /// **Cause:** Some data has incorrect bytes + #[error("TryFromSlice: {0}")] + TryFromSlice(String), + #[error("Other Kes Validation Error: {0}")] + Other(String), +} + +/// Validation function for Kes +pub type KesValidation<'a> = Box Result<(), KesValidationError> + Send + Sync + 'a>; + +#[derive(Error, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum KesSignatureError { + /// **Cause:** Current KES period is before the operational certificate's + /// start period. + #[error( + "KES Before Start OCert: OCert Start Period={}, Current Period={}", + ocert_start_period, + current_period + )] + KesBeforeStartOcert { + ocert_start_period: u64, + current_period: u64, + }, + /// **Cause:** Current KES period exceeds the operational certificate's + /// validity period. + #[error( + "KES After End OCert: Current Period={}, OCert Start Period={}, Max KES Evolutions={}", + current_period, + ocert_start_period, + max_kes_evolutions + )] + KesAfterEndOcert { + current_period: u64, + ocert_start_period: u64, + max_kes_evolutions: u64, + }, + /// **Cause:** The KES signature on the block header is cryptographically invalid. + #[error( + "Invalid KES Signature OCert: Current Period={}, OCert Start Period={}, Reason={}", + current_period, + ocert_start_period, + reason + )] + InvalidKesSignatureOcert { + current_period: u64, + ocert_start_period: u64, + reason: String, + }, +} + +#[derive(Error, Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)] +pub enum OperationalCertificateError { + /// **Cause:** The operational certificate is malformed. + #[error("Malformed Signature OCert: Reason={}", reason)] + MalformedSignatureOcert { reason: String }, + /// **Cause:** The cold key signature on the operational certificate is invalid. + /// The OCert was not properly signed by the pool's cold key. + #[error( + "Invalid Signature OCert: Issuer={}, Pool ID={}", + hex::encode(issuer), + hex::encode(pool_id) + )] + InvalidSignatureOcert { issuer: Vec, pool_id: PoolId }, + /// **Cause:** The operational certificate counter in the header is not greater + /// than the last counter used by this pool. + #[error( + "Counter Too Small OCert: Latest Counter={}, Declared Counter={}", + latest_counter, + declared_counter + )] + CounterTooSmallOcert { + latest_counter: u64, + declared_counter: u64, + }, + /// **Cause:** OCert counter jumped by more than 1. While not strictly invalid, + /// this is suspicious and may indicate key compromise. (Praos Only) + #[error( + "Counter Over Incremented OCert: Latest Counter={}, Declared Counter={}", + latest_counter, + declared_counter + )] + CounterOverIncrementedOcert { + latest_counter: u64, + declared_counter: u64, + }, + /// **Cause:** No counter found for this key hash (not a stake pool or genesis delegate) + #[error("No Counter For Key Hash OCert: Pool ID={}", hex::encode(pool_id))] + NoCounterForKeyHashOcert { pool_id: PoolId }, +} diff --git a/modules/block_kes_validator/Cargo.toml b/modules/block_kes_validator/Cargo.toml new file mode 100644 index 00000000..f307dd8c --- /dev/null +++ b/modules/block_kes_validator/Cargo.toml @@ -0,0 +1,28 @@ +# Acropolis Block KES Validator + +[package] +name = "acropolis_module_block_kes_validator" +version = "0.1.0" +edition = "2021" +authors = ["Golddy "] +description = "Validate the KES in the block header" +license = "Apache-2.0" + +[dependencies] +acropolis_common = { path = "../../common" } + +caryatid_sdk = { workspace = true } + +anyhow = { workspace = true } +config = { workspace = true } +hex = { workspace = true } +imbl = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +thiserror = "2.0.17" +pallas = { workspace = true } + +kes-summed-ed25519 = { git = "https://github.com/txpipe/kes", rev = "f69fb357d46f6a18925543d785850059569d7e78" } + +[lib] +path = "src/block_kes_validator.rs" diff --git a/modules/block_kes_validator/src/block_kes_validator.rs b/modules/block_kes_validator/src/block_kes_validator.rs new file mode 100644 index 00000000..25eafcb1 --- /dev/null +++ b/modules/block_kes_validator/src/block_kes_validator.rs @@ -0,0 +1,224 @@ +//! Acropolis Block KES Validator module for Caryatid +//! Validate KES signatures in the block header + +use acropolis_common::{ + messages::{CardanoMessage, Message}, + state_history::{StateHistory, StateHistoryStore}, + BlockInfo, BlockStatus, +}; +use anyhow::Result; +use caryatid_sdk::{module, Context, Module, Subscription}; +use config::Config; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, info, info_span, Instrument}; +mod state; +use state::State; + +use crate::kes_validation_publisher::KesValidationPublisher; +mod kes_validation_publisher; +mod ouroboros; + +const DEFAULT_VALIDATION_KES_PUBLISHER_TOPIC: (&str, &str) = + ("validation-kes-publisher-topic", "cardano.validation.kes"); + +const DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC: (&str, &str) = ( + "bootstrapped-subscribe-topic", + "cardano.sequence.bootstrapped", +); +const DEFAULT_BLOCKS_SUBSCRIBE_TOPIC: (&str, &str) = + ("blocks-subscribe-topic", "cardano.block.proposed"); +const DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = ( + "protocol-parameters-subscribe-topic", + "cardano.protocol.parameters", +); +const DEFAULT_SPO_STATE_SUBSCRIBE_TOPIC: (&str, &str) = + ("spo-state-subscribe-topic", "cardano.spo.state"); + +/// Block KES Validator module +#[module( + message_type(Message), + name = "block-kes-validator", + description = "Validate the KES signatures in the block header" +)] + +pub struct BlockKesValidator; + +impl BlockKesValidator { + #[allow(clippy::too_many_arguments)] + async fn run( + history: Arc>>, + kes_validation_publisher: KesValidationPublisher, + mut bootstrapped_subscription: Box>, + mut blocks_subscription: Box>, + mut protocol_parameters_subscription: Box>, + mut spo_state_subscription: Box>, + ) -> Result<()> { + let (_, bootstrapped_message) = bootstrapped_subscription.read().await?; + let genesis = match bootstrapped_message.as_ref() { + Message::Cardano((_, CardanoMessage::GenesisComplete(complete))) => { + complete.values.clone() + } + _ => panic!("Unexpected message in genesis completion topic: {bootstrapped_message:?}"), + }; + + // Consume initial protocol parameters + let _ = protocol_parameters_subscription.read().await?; + + loop { + // Get a mutable state + let mut state = history.lock().await.get_or_init_with(State::new); + let mut current_block: Option = None; + + let (_, message) = blocks_subscription.read().await?; + match message.as_ref() { + Message::Cardano((block_info, CardanoMessage::BlockAvailable(block_msg))) => { + // handle rollback here + if block_info.status == BlockStatus::RolledBack { + state = history.lock().await.get_rolled_back_state(block_info.number); + } + current_block = Some(block_info.clone()); + let is_new_epoch = block_info.new_epoch && block_info.epoch > 0; + + if is_new_epoch { + // read epoch boundary messages + let protocol_parameters_message_f = protocol_parameters_subscription.read(); + let spo_state_message_f = spo_state_subscription.read(); + + let (_, protocol_parameters_msg) = protocol_parameters_message_f.await?; + let span = info_span!( + "block_kes_validator.handle_protocol_parameters", + epoch = block_info.epoch + ); + span.in_scope(|| match protocol_parameters_msg.as_ref() { + Message::Cardano((block_info, CardanoMessage::ProtocolParams(msg))) => { + Self::check_sync(¤t_block, block_info); + state.handle_protocol_parameters(msg); + } + _ => error!("Unexpected message type: {protocol_parameters_msg:?}"), + }); + + let (_, spo_state_msg) = spo_state_message_f.await?; + let span = info_span!( + "block_kes_validator.handle_spo_state", + epoch = block_info.epoch + ); + span.in_scope(|| match spo_state_msg.as_ref() { + Message::Cardano((block_info, CardanoMessage::SPOState(msg))) => { + Self::check_sync(¤t_block, block_info); + state.handle_spo_state(msg); + } + _ => error!("Unexpected message type: {spo_state_msg:?}"), + }); + } + + let span = + info_span!("block_kes_validator.validate", block = block_info.number); + async { + let result = state + .validate_block_kes(block_info, &block_msg.header, &genesis) + .map_err(|e| *e); + + // Update the operational certificate counter + // When block is validated successfully + // Reference + // https://github.com/IntersectMBO/ouroboros-consensus/blob/e3c52b7c583bdb6708fac4fdaa8bf0b9588f5a88/ouroboros-consensus-protocol/src/ouroboros-consensus-protocol/Ouroboros/Consensus/Protocol/Praos.hs#L508 + if let Ok(Some((pool_id, updated_sequence_number))) = result.as_ref() { + state.update_ocert_counter(*pool_id, *updated_sequence_number); + } + + if let Err(e) = kes_validation_publisher + .publish_kes_validation(block_info, result) + .await + { + error!("Failed to publish KES validation: {e}") + } + } + .instrument(span) + .await; + } + _ => error!("Unexpected message type: {message:?}"), + } + + // Commit the new state + if let Some(block_info) = current_block { + history.lock().await.commit(block_info.number, state); + } + } + } + + pub async fn init(&self, context: Arc>, config: Arc) -> Result<()> { + // Publish topics + let validation_kes_publisher_topic = config + .get_string(DEFAULT_VALIDATION_KES_PUBLISHER_TOPIC.0) + .unwrap_or(DEFAULT_VALIDATION_KES_PUBLISHER_TOPIC.1.to_string()); + info!("Creating validation KES publisher on '{validation_kes_publisher_topic}'"); + + // Subscribe topics + let bootstrapped_subscribe_topic = config + .get_string(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating subscriber for bootstrapped on '{bootstrapped_subscribe_topic}'"); + + let blocks_subscribe_topic = config + .get_string(DEFAULT_BLOCKS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_BLOCKS_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating blocks subscription on '{blocks_subscribe_topic}'"); + + let protocol_parameters_subscribe_topic = config + .get_string(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating subscriber for protocol parameters on '{protocol_parameters_subscribe_topic}'"); + + let spo_state_subscribe_topic = config + .get_string(DEFAULT_SPO_STATE_SUBSCRIBE_TOPIC.0) + .unwrap_or(DEFAULT_SPO_STATE_SUBSCRIBE_TOPIC.1.to_string()); + info!("Creating spo state subscription on '{spo_state_subscribe_topic}'"); + + // publishers + let kes_validation_publisher = + KesValidationPublisher::new(context.clone(), validation_kes_publisher_topic); + + // Subscribers + let bootstrapped_subscription = context.subscribe(&bootstrapped_subscribe_topic).await?; + let blocks_subscription = context.subscribe(&blocks_subscribe_topic).await?; + let protocol_parameters_subscription = + context.subscribe(&protocol_parameters_subscribe_topic).await?; + let spo_state_subscription = context.subscribe(&spo_state_subscribe_topic).await?; + + // state history + let history = Arc::new(Mutex::new(StateHistory::::new( + "block_kes_validator", + StateHistoryStore::default_block_store(), + ))); + + // Start run task + context.run(async move { + Self::run( + history, + kes_validation_publisher, + bootstrapped_subscription, + blocks_subscription, + protocol_parameters_subscription, + spo_state_subscription, + ) + .await + .unwrap_or_else(|e| error!("Failed: {e}")); + }); + + Ok(()) + } + + /// Check for synchronisation + fn check_sync(expected: &Option, actual: &BlockInfo) { + if let Some(ref block) = expected { + if block.number != actual.number { + error!( + expected = block.number, + actual = actual.number, + "Messages out of sync" + ); + } + } + } +} diff --git a/modules/block_kes_validator/src/kes_validation_publisher.rs b/modules/block_kes_validator/src/kes_validation_publisher.rs new file mode 100644 index 00000000..fe9254ea --- /dev/null +++ b/modules/block_kes_validator/src/kes_validation_publisher.rs @@ -0,0 +1,52 @@ +use acropolis_common::{ + messages::{CardanoMessage, Message}, + validation::{KesValidationError, ValidationError, ValidationStatus}, + BlockInfo, PoolId, +}; +use caryatid_sdk::Context; +use std::sync::Arc; +use tracing::error; + +/// Message publisher for Block header KES Validation Result +pub struct KesValidationPublisher { + /// Module context + context: Arc>, + + /// Topic to publish on + topic: String, +} + +impl KesValidationPublisher { + /// Construct with context and topic to publish on + pub fn new(context: Arc>, topic: String) -> Self { + Self { context, topic } + } + + pub async fn publish_kes_validation( + &self, + block: &BlockInfo, + validation_result: Result, KesValidationError>, + ) -> anyhow::Result<()> { + let validation_status = match validation_result { + Ok(_) => ValidationStatus::Go, + Err(error) => { + error!( + "KES validation failed: {} of block {}", + error.clone(), + block.number + ); + ValidationStatus::NoGo(ValidationError::from(error)) + } + }; + self.context + .message_bus + .publish( + &self.topic, + Arc::new(Message::Cardano(( + block.clone(), + CardanoMessage::BlockValidation(validation_status), + ))), + ) + .await + } +} diff --git a/modules/block_kes_validator/src/ouroboros/data/4490511.cbor b/modules/block_kes_validator/src/ouroboros/data/4490511.cbor new file mode 100644 index 00000000..3c5ac69a --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/data/4490511.cbor @@ -0,0 +1 @@ +828f1a0044850f1a00448e005820f8084c61b6a238acec985b59310b6ecec49c0ab8352249afd7268da5cff2a45758209180d818e69cd997e34663c418a648c076f2e19cd4194e486e159d8580bc6cda58206d930cc9d1baade5cd1c70fbc025d3377ce946760c48e511d1abdf8acff6ff1c82584036ec5378d1f5041a59eb8d96e61de96f0950fb41b49ff511f7bc7fd109d4383e1d24be7034e6749c6612700dd5ceb0c66577b88a19ae286b1321d15bce1ab7365850405aa370ff009544a2be4aa5bc52c4456333f4f9b6571d66c5590d2e5629d08b3a3609f4bd0502ed5d0be1abdb7f2ab76aaeae47fe111b0335a4e4def64693162794b8d3c1ca71500f16b1e244724c03825840da5ccc9f8fd62f6c290b5bb2ed9a4258fc9481dd8a0ac80f8936702ad7709a87814d14dca02ce22d7a3e150d171e57914cc058081941e3c6737987524076b6935850402fcbd1d50e4b42fa81dce2eada18df1803af49273d05cbe4a1870983d86759a61005d5d942a53fb68389fe119b5dae823fa3cd6668dc257556bc52086fd879b082dace60edcf0cd592f63103bcd10e0358201033376be025cb705fd8dd02eda11cc73975a062b5d14ffd74d6ff69e69a2ff75820ae981f4a58d135f98d0a0c5aab9fc04944c8409f65187f9778b57905e43769570000584008d56fe9c28eeeb99bda8920a9887f468f1d74bb03c37ef3caffffbee68d88eb33341498ac145a2422c77546db61c2da782587cdf934d69b113ce52ab8bd8b0b02005901c03de52bc307718194c07824cdc0d2fa016e60b19bc1c7162978fdac55cf43d2ac7f3aea635ffc98d53570a98ac19c703dfda935aac478d84f0281f7c8bc7a180b6aa7e738e9381ebb86baf842af4ee229a3e3908cfec16153f1dd5e21dba0316e7625e4c2a8db495d80ca3c93862fcf4ad0a8a79a20f7d3d9733f9dccbb8ac6d623bbec2bb6dbf170a3c33d32a15d2cc02b3ce78c8d61af768fa82067254b2fe3bd7a1e5cdacf71b50998eec98a48b6970eacdc74d5ae40224f95850fbd88df96e192767c641a70ebf2bb9dc7d32a1e36d1113c09f5591622b35aed5c06bf7b894b882c56bd8e12b7da5b16a341e59d3239a3d14f9d0a5ac006dfdc258e215eacb563954ea234797754b9276f9746d1d6589e10f0dfda8130d422db636d02dec7c8b45ff97de40ac541421e3f2d1aed4246cc5bb54600fa567275046a979f3b4d067c0869732129234df68f1150b09285ecf71385034c79ace9e74186bd811770fcd43a449aa9466165731f92ae73ba4ef9c665a1f8851420a8b1568b711fabca735f474a6a800751f73fbbb3ed2ace0c8bdb2c14eed4032b085dd0ac8f6b0d984a441d337ffe6903aa794728a56360e106c84fc1b943af0e383fd1f1939a2416 \ No newline at end of file diff --git a/modules/block_kes_validator/src/ouroboros/data/4556956.cbor b/modules/block_kes_validator/src/ouroboros/data/4556956.cbor new file mode 100644 index 00000000..68eb2b74 --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/data/4556956.cbor @@ -0,0 +1 @@ +828f1a0045889c1a0058e1515820d5852411975dc3c5da5042f48372aa533b96c6d24017f9291cf65e730b5835555820330b133b489d79d039c7feceefe5acb9149f636d5d84943788ce8783c1c098245820e9dffe03f31be0431b2199696250f94ec599916ff5618080b428ac7c5ea5f6108258403962c816bc3989fe2b817e3a8f8deb54f6b0cf69105c7b041b3f1e90dfbf05a96c8912e11046647053d8e0c43fddf984082231946d3250a4182659e9b18cdd39585084e17ca6179f8d416f9bdc11f39714cb546ba0bc2274cf9d2c560a83d76942a31c55f30e95b310a9db9726c81cf37913f1a687dee0f21b5c553d863d393a4b1128425bb11901a6dc0a5bcd5d2eae82028258400018db71ca133eaa423d45ebfc33b6dbe2d35296f89e4ca3fe09810d8805255414ad3b5c97d0c3c7eca86d8d4c07035aa479bd6af9d9e986b8c4f9a5f76c9382585024df0024d93acdc1eb8b2a3a7b0f348fba765a4f27590eb6420458b34507ee655089866644bee297644c63fe8bc2aa3574a6b6a76e34fcaa5ce4010dd2648fb7118fbc7b6899024fd6369c43155c450f0358201033376be025cb705fd8dd02eda11cc73975a062b5d14ffd74d6ff69e69a2ff7582029429acc1d7d18b05039b379a7fe94dc95bd86631433ecb41b523e7d04a974c801015840c2b36ecd955d90a8adac85b4aa8a3dbdf022108794a255763c84c460db3b6eca45fb26dd5bc5cce9812f3199527e2c249473de89bd459888f12c964f58b1fc0c01005901c0f04b785404897ff6e361e76bc9a5f62c5b1af03e255a6110b09fc3be9d50ba1f2314076c09320a9bc9b755b6f9483325f9dbdab108bd8e412a63b1e15e54d60d17a33403716c4d5a57e851b90f21569cfdf4b7cd74ceb725d9173714d8ea7dda528f6848b0001f921382cdd64ae1ba60d7f725af64f10c572ed56ae28e187e4fe11c45ea269b508bcb6c08748be32e9bcf27df9c8853dc7272a278e60120f7fbe927d8614bbc3a1d9bab4a00199313f2f81f6b62a2f35496c04c1d7f8dae984695270c741298d567251066675609c71743a4cf61b525eabbea0f27d14e53a558210dbc7397354abda4d323b5b82460b6c8a1cb654f62af1962bfd3ec1a818e016be9e84c4ea1007a2edb5d392ea5cd84e4d92f2dd92fb1d80502f18df873a29915616650b4a697289bfec648eb72f0867bb1c6bd9c85fc25215078139f770a30e79b0f764bbef90a3933a82bb4f190e418f361c0426da2521c47c9330e96c876d8ec496117bf9a3cdb89420018e33e2977077fff74120fe8385ecd7729aee75742b31c09d4112ac7e777aced184056e8de92c3519e26ffdc98d85e3bd46725488bcad48a5da411985577277605a9c65a82f5224c179ac2da52be2e497b37de6b \ No newline at end of file diff --git a/modules/block_kes_validator/src/ouroboros/data/7854823.cbor b/modules/block_kes_validator/src/ouroboros/data/7854823.cbor new file mode 100644 index 00000000..1199b9b6 --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/data/7854823.cbor @@ -0,0 +1 @@ +828a1a0077dae71a046344c15820991e0770751806103894d8fffaf86fcc90f238c6ac00bdc7cb0a953e4a59fd93582041ec779d32f5ca1fc81a8cb4152e6bc1f0ab569abe1c0de8b66d76242d23a70f5820b5b6a6b790e6926f6ba856255aaf3eb5192cc80c03eeec7469cd3f64344bafb78258403f42ef44b8525ee45ca45a094191f00b359e9205a400b0607c87fe488f3b44442afc9e7c3e28ded9080c71d84eb7840ecb2e7240721ee243b45a761048ad6c53585054ac3b2bc94b54705b951146a83cba92041fdf537644628c64e9549365e74c10502fb7c23f34d7daa8327843f728513b225044a9390f86fb2a83951c4a39a13c17472c71cf50d74f1ce12d04dc85e9021a00015b1158202c1657b98fabe337cff8811a0b198dd36dfa61a415fdc5e5759f43cfa1103c48845820249caec7a56b2ede7dfdb956178fc6d7d5c96b5f35e63ddc7987e9261ac277c70b1902065840c4bbbda856bebac009d32c276080a54bb6322ab3252d09ad4c7c4a3e3f9b8931dd81b1795dbd165d8d3ca493d77966eb682bfff0edc1d151ef0ad42efb91ba0d8207005901c03824c9afe0176e3abf9e9bcf6972fb8c408d218fe0eab8026780a9995c7cc8fbf2a6f7d54fcb138bcff109b578d020baf5dbae751dad81980d52146afc615905ea103db0ff10d488fc9d0dbfa410066768d93c566fc346ff4b81bd41137df20c446771dd0d24399d61a9caeddce7abb55e6647247158ac252cf87e56871f4535857f6dbbf90b99d9b4bd6ca20705f7deff4e7d3220c002c4e7e5d96927751d96e08c92886ac223a4f7141625da97819a3b574dbf80ca50418f33ce2858b41f2fe63290bda8fcfe8382defc7ce721ed6d96466efe69a0bcb31a3951447b3e73c07e167240a99b492a71b0f9a6af321620d16b944b7b7ce89353ce0b65fc19f3744357603fca23f2b0056c607f817b24ee15b51008ab43e470016e46c0a1589bf184b46b79e808750f7a96fa747387414b9e691b95e7003e825308e4cd4fcf67d87233cb90282e3a5e65ce5cd3448e2a5d5ee6b232a431a45c252ed9a04dbee9cf89c5d5f975481b3be2623bf4a517205b69516d59e0a1877643e25485846911252315499cc705061ee74bd8a6ca2a9ffe46f2905839372b008074d775b6f6f7bb3f245263d306f0194133f947909ff4af9f9815800f1173ef7ea602886f55e1fd \ No newline at end of file diff --git a/modules/block_kes_validator/src/ouroboros/kes.rs b/modules/block_kes_validator/src/ouroboros/kes.rs new file mode 100644 index 00000000..0ecbfb2d --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/kes.rs @@ -0,0 +1,219 @@ +use kes_summed_ed25519::{self as kes, kes::Sum6KesSig, traits::KesSig}; +use std::{array::TryFromSliceError, ops::Deref}; +use thiserror::Error; + +// ------------------------------------------------------------------- PublicKey + +/// KES public key +pub struct PublicKey(kes::PublicKey); + +impl PublicKey { + /// Size of a KES public key, in bytes; + pub const SIZE: usize = 32; +} + +impl AsRef<[u8]> for PublicKey { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl Deref for PublicKey { + type Target = [u8; Self::SIZE]; + + fn deref(&self) -> &Self::Target { + self.0.as_bytes().try_into().unwrap_or_else(|e| { + unreachable!( + "Impossible! Failed to convert KES public key ({}) back to slice of known size: {e:?}", + hex::encode(self.0), + ) + }) + } +} + +impl From<&[u8; PublicKey::SIZE]> for PublicKey { + fn from(bytes: &[u8; PublicKey::SIZE]) -> Self { + PublicKey(kes::PublicKey::from_bytes(bytes).unwrap_or_else(|e| { + unreachable!( + "Impossible! Failed to create a KES public key from a slice ({}) of known size: {e:?}", + hex::encode(bytes) + ) + })) + } +} + +impl TryFrom<&[u8]> for PublicKey { + type Error = TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self::from(<&[u8; Self::SIZE]>::try_from(bytes)?)) + } +} + +// ------------------------------------------------------------------- Signature + +/// KES signature +pub struct Signature(Sum6KesSig); + +impl Signature { + /// Size of a KES signature, in bytes; + pub const SIZE: usize = Sum6KesSig::SIZE; + + /// Verify the KES signature + pub fn verify(&self, kes_period: u32, kes_pk: &PublicKey, msg: &[u8]) -> Result<(), Error> { + Ok(self.0.verify(kes_period, &kes_pk.0, msg)?) + } +} + +impl From<&[u8; Self::SIZE]> for Signature { + fn from(bytes: &[u8; Self::SIZE]) -> Self { + Signature(Sum6KesSig::from_bytes(bytes).unwrap_or_else(|e| { + unreachable!("Impossible! Failed to create a KES signature from a slice ({}) of known size: {e:?}", hex::encode(bytes)) + })) + } +} + +impl TryFrom<&[u8]> for Signature { + type Error = TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self::from(<&[u8; Self::SIZE]>::try_from(bytes)?)) + } +} + +impl From<&Signature> for [u8; 448] { + fn from(sig: &Signature) -> Self { + sig.0.to_bytes() + } +} + +// ----------------------------------------------------------------------- Error + +/// KES error +#[derive(Error, Debug)] +pub enum Error { + #[error("KES error: {0}")] + Kes(#[from] kes_summed_ed25519::errors::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use kes_summed_ed25519::{kes::Sum6Kes, traits::KesSk}; + + // ------------------------------------------------------------------- SecretKey + + /// KES secret key + pub struct SecretKey<'a>(Sum6Kes<'a>); + + impl SecretKey<'_> { + /// Create a new KES secret key + pub fn from_bytes(sk_bytes: &mut Vec) -> Result, Error> { + // TODO: extend() could potentially re-allocate memory to a new location and copy the sk_bytes. + // This would leave the original memory containing the secret key without being wiped. + sk_bytes.extend([0u8; 4]); // default to period = 0 + let sum_6_kes = Sum6Kes::from_bytes(sk_bytes.as_mut_slice())?; + Ok(SecretKey(sum_6_kes)) + } + + /// Get the current period of the KES secret key + pub fn get_period(&self) -> u32 { + self.0.get_period() + } + + /// Update the KES secret key to the next period + pub fn update(&mut self) -> Result<(), Error> { + Ok(self.0.update()?) + } + } + + impl From<&SecretKey<'_>> for PublicKey { + fn from(sk: &SecretKey<'_>) -> Self { + PublicKey(sk.0.to_pk()) + } + } + + #[test] + fn kes_key_evolution() { + let mut kes_sk_bytes = hex::decode( + "68b77b6e61925be0499d1445fd9210cec5bdfd5dd92662802eb2720ff70bc68fd89\ + 64580ff18bd2b232eb716dfbbeef82e2844b466ddd5dacaad9f15d3c753b3483541\ + 41e973d039b1147c48e71e5b7cadc6deb28c86e4ae4fc26e8bbe1695c3374d4eb10\ + 94a7a698722894301546466c750947778b18ac3270397efd2eced4d25ced55d2bd2\ + c09e7c0fa7b849d41787ca11defc91609d930a9870881a56a587bff20b2c5c59f63\ + ccb008be495917da3fcae536d05401b6771bb1f9356f031b3ddadbffbc426a9a23e\ + 34274b187f7e93892e990644f6273772a02d3e38bee7459ed6a9bb5760fe012e47a\ + 2e75880125e7fb072b2b7a626a5375e2039d8d748cb8ad4dd02697250d3155eee39\ + 308ecc2925405a8c15e1cbe556cc4315d43ee5101003639bcb33bd6e27da3885888\ + d7cca20b05cadbaa53941ef5282cde8f377c3bd0bf732cfac6b5d4d5597a1f72d81\ + bc0d8af634a4c760b309fe8959bbde666ff10310377b313860bd52d56fd7cb14963\ + 3beb1eb2e0076111df61e570a042f7cebae74a8de298a6f114938946230db42651e\ + a4eddf5df2d7d2f3016464073da8a9dc715817b43586a61874e576da7b47a2bb6c2\ + e19d4cbd5b1b39a24427e89b812cce6d30e0506e207f1eaab313c45a236068ea319\ + 958474237a5ffe02736e1c51c02a05999816c9253a557f09375c83acf5d7250f3bb\ + c638e10c58fb274e2002eed841ecef6a9cbc57c3157a7c3cf47e66b1741e8173b66\ + 76ac973bc9715027a3225087cabad45407b891416330485891dc9a3875488a26428\ + d20d581b629a8f4f42e3aa00cbcaae6c8e2b8f3fe033b874d1de6a3f8c321c92b77\ + 643f00d28e", + ) + .unwrap(); + + let mut kes_sk = SecretKey::from_bytes(&mut kes_sk_bytes).unwrap(); + assert_eq!( + hex::encode(PublicKey::from(&kes_sk)), + "2e5823037de29647e495b97d9dd7bf739f7ebc11d3701c8d0720f55618e1b292" + ); + + assert_eq!(kes_sk.get_period(), 0); + + kes_sk.update().unwrap(); + assert_eq!(kes_sk.get_period(), 1); + + kes_sk.update().unwrap(); + assert_eq!(kes_sk.get_period(), 2); + } + + #[test] + fn kes_signature_verify() { + let kes_pk_bytes = + hex::decode("2e5823037de29647e495b97d9dd7bf739f7ebc11d3701c8d0720f55618e1b292") + .unwrap(); + let kes_pk = PublicKey::try_from(&kes_pk_bytes[..]).unwrap(); + let kes_signature_bytes = hex::decode( + "20f1c8f9ae672e6ec75b0aa63a85e7ab7865b95f6b2907a26b54c14f49184ab52cf\ + 98ef441bb71de50380325b34f16d84fc78d137467a1b49846747cf8ee4701c56f08\ + f198b94c468d46b67b271f5bc30ab2ad14b1bdbf2be0695a00fe4b02b3060fa5212\ + 8f4cce9c5759df0ba8d71fe99456bd2e333671e45110908d03a2ec3b38599d26adf\ + 182ba63f79900fdb2732947cf8e940a4cf1e8db9b4cf4c001dbd37c60d0e38851de\ + 4910807896153be455e13161342d4c6f7bb3e4d2d35dbbbba0ebcd161be2f1ec030\ + d2f5a6059ac89dfa70dc6b3d0bc2da179c62ae95c4f9c7ad9c0387b35bf2b45b325\ + d1e0a18c0c783a0779003bf23e7a6b00cc126c5e3d51a57d41ff1707a76fb2c306a\ + 67c21473b41f1d9a7f64a670ec172a2421da03d796fa97086de8812304f4f96bd45\ + 243d0a2ad6c48a69d9e2c0afbb1333acee607d18eb3a33818c3c9d5bb72cade8893\ + 79008bf60d436298cb0cfc6159332cb1af1de4f1d64e79c399d058ac4993704eed6\ + 7917093f89db6cde830383e69aa400ba3225087cabad45407b891416330485891dc\ + 9a3875488a26428d20d581b629a8f4f42e3aa00cbcaae6c8e2b8f3fe033b874d1de\ + 6a3f8c321c92b77643f00d28e", + ) + .unwrap(); + let kes_signature = Signature::try_from(&kes_signature_bytes[..]).unwrap(); + let kes_period = 36u32; + let kes_msg = hex::decode( + "8a1a00a50f121a0802d24458203deea82abe788d260b8987a522aadec86c9f098e8\ + 8a57d7cfcdb24f474a7afb65820cad3c900ca6baee9e65bf61073d900bfbca458ee\ + ca6d0b9f9931f5b1017a8cd65820576d49e98adfab65623dc16f9fff2edd210e8dd\ + 1d4588bfaf8af250beda9d3c7825840d944b8c81000fc1182ec02194ca9eca510fd\ + 84995d22bfe1842190b39d468e5ecbd863969e0c717b0071a371f748d44c895fa92\ + 33094cefcd3107410baabb19a5850f2a29f985d37ca8eb671c2847fab9cc45c9373\ + 8a430b4e43837e7f33028b190a7e55152b0e901548961a66d56eebe72d616f9e68f\ + d13e9955ccd8611c201a5b422ac8ef56af74cb657b5b868ce9d850f1945d1582063\ + 9d4986d17de3cac8079a3b25d671f339467aa3a9948e29992dafebf90f719f84582\ + 02e5823037de29647e495b97d9dd7bf739f7ebc11d3701c8d0720f55618e1b29217\ + 1903e958401feeeabc7460b19370f4050e986b558b149fdc8724b4a4805af8fe45c\ + 8e7a7c6753894ad7a1b9c313da269ddc5922e150da3b378977f1dfea79fc52fd2c1\ + 2f08820901", + ) + .unwrap(); + assert!(kes_signature.verify(kes_period, &kes_pk, &kes_msg).is_ok()); + } +} diff --git a/modules/block_kes_validator/src/ouroboros/kes_validation.rs b/modules/block_kes_validator/src/ouroboros/kes_validation.rs new file mode 100644 index 00000000..34ef56ac --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/kes_validation.rs @@ -0,0 +1,468 @@ +use std::collections::HashSet; + +use acropolis_common::{ + crypto::keyhash_224, + validation::{ + KesSignatureError, KesValidation, KesValidationError, OperationalCertificateError, + }, + GenesisDelegates, PoolId, +}; +use imbl::HashMap; +use pallas::{crypto::key::ed25519, ledger::traverse::MultiEraHeader}; + +use crate::ouroboros::{kes, praos, tpraos}; + +#[derive(Copy, Clone)] +pub struct OperationalCertificate<'a> { + /// The operational hot key + pub operational_cert_hot_vkey: &'a [u8], + /// The sequence number of the operational certificate + pub operational_cert_sequence_number: u64, + /// The KES period of the operational certificate + pub operational_cert_kes_period: u64, + /// The signature of the operational certificate + pub operational_cert_sigma: &'a [u8], +} + +pub fn validate_kes_signature( + slot_kes_period: u64, + opcert_kes_period: u64, + header_body: &[u8], + public_key: &kes::PublicKey, + signature: &kes::Signature, + max_kes_evolutions: u64, +) -> Result<(), KesSignatureError> { + if opcert_kes_period > slot_kes_period { + return Err(KesSignatureError::KesBeforeStartOcert { + ocert_start_period: opcert_kes_period, + current_period: slot_kes_period, + }); + } + + if slot_kes_period >= opcert_kes_period + max_kes_evolutions { + return Err(KesSignatureError::KesAfterEndOcert { + current_period: slot_kes_period, + ocert_start_period: opcert_kes_period, + max_kes_evolutions, + }); + } + + let kes_period = (slot_kes_period - opcert_kes_period) as u32; + + signature.verify(kes_period, public_key, header_body).map_err(|error| { + KesSignatureError::InvalidKesSignatureOcert { + current_period: slot_kes_period, + ocert_start_period: opcert_kes_period, + reason: error.to_string(), + } + })?; + + Ok(()) +} + +pub fn validate_operational_certificate<'a>( + certificate: OperationalCertificate<'a>, + pool_id: &PoolId, + issuer: &ed25519::PublicKey, + latest_sequence_number: u64, + is_praos: bool, +) -> Result<(), OperationalCertificateError> { + // Verify the Operational Certificate signature + let signature = + ed25519::Signature::try_from(certificate.operational_cert_sigma).map_err(|error| { + OperationalCertificateError::MalformedSignatureOcert { + reason: error.to_string(), + } + })?; + + let declared_sequence_number = certificate.operational_cert_sequence_number; + + // Check the sequence number of the operational certificate. It should either be the same + // as the latest known sequence number for the issuer or one greater. + if declared_sequence_number < latest_sequence_number { + return Err(OperationalCertificateError::CounterTooSmallOcert { + latest_counter: latest_sequence_number, + declared_counter: declared_sequence_number, + }); + } + + // this is only for praos protocol + if is_praos && (declared_sequence_number - latest_sequence_number) > 1 { + return Err(OperationalCertificateError::CounterOverIncrementedOcert { + latest_counter: latest_sequence_number, + declared_counter: declared_sequence_number, + }); + } + + // The opcert message is a concatenation of the KES vkey, the sequence number, and the kes period + // Reference + // https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/libs/cardano-protocol-tpraos/src/Cardano/Protocol/TPraos/OCert.hs#L144 + let mut message = Vec::new(); + message.extend_from_slice(certificate.operational_cert_hot_vkey); + message.extend_from_slice(&certificate.operational_cert_sequence_number.to_be_bytes()); + message.extend_from_slice(&certificate.operational_cert_kes_period.to_be_bytes()); + if !issuer.verify(&message, &signature) { + return Err(OperationalCertificateError::InvalidSignatureOcert { + issuer: issuer.as_ref().to_vec(), + pool_id: *pool_id, + }); + } + + Ok(()) +} + +/// This function check block header's KES signature and operational certificate +/// return validation functions for KES signature and operational certificate +/// and the pool id and declared sequence number (which will be used to update the operational certificate counter when validation is successful) +/// Reference +/// https://github.com/IntersectMBO/ouroboros-consensus/blob/e3c52b7c583bdb6708fac4fdaa8bf0b9588f5a88/ouroboros-consensus-protocol/src/ouroboros-consensus-protocol/Ouroboros/Consensus/Protocol/Praos.hs#L612 +pub fn validate_block_kes<'a>( + header: &'a MultiEraHeader, + ocert_counters: &'a HashMap, + active_spos: &'a HashSet, + genesis_delegs: &'a GenesisDelegates, + slots_per_kes_period: u64, + max_kes_evolutions: u64, +) -> Result<(Vec>, PoolId, u64), Box> { + let is_praos = matches!(header, MultiEraHeader::BabbageCompatible(_)); + + let issuer_vkey = header.issuer_vkey().ok_or(Box::new(KesValidationError::Other( + "Block header missing issuer verification key".to_string(), + )))?; + let issuer = ed25519::PublicKey::from( + <[u8; ed25519::PublicKey::SIZE]>::try_from(issuer_vkey).map_err(|_| { + Box::new(KesValidationError::Other( + "Issuer verification key has invalid length (expected 32 bytes)".to_string(), + )) + })?, + ); + let pool_id = PoolId::from(keyhash_224(issuer_vkey)); + + let slot_kes_period = header.slot() / slots_per_kes_period; + let cert = operational_cert(header).ok_or(Box::new(KesValidationError::Other( + "Block header missing operational certificate".to_string(), + )))?; + let body_sig = body_signature(header).ok_or(Box::new(KesValidationError::Other( + "Block header missing KES body signature".to_string(), + )))?; + let raw_header_body = header.header_body_cbor().ok_or(Box::new(KesValidationError::Other( + "Block header body CBOR not available".to_string(), + )))?; + + let declared_sequence_number = cert.operational_cert_sequence_number; + let latest_sequence_number = if is_praos { + praos::latest_issue_no_praos(ocert_counters, active_spos, &pool_id) + } else { + tpraos::latest_issue_no_tpraos(ocert_counters, active_spos, genesis_delegs, &pool_id) + } + .ok_or(Box::new(KesValidationError::NoOCertCounter { pool_id }))?; + + Ok(( + vec![ + Box::new(move || { + validate_kes_signature( + slot_kes_period, + cert.operational_cert_kes_period, + raw_header_body, + &kes::PublicKey::try_from(cert.operational_cert_hot_vkey).map_err(|_| { + KesValidationError::Other( + "Invalid operational certificate hot vkey".to_string(), + ) + })?, + &kes::Signature::try_from(body_sig).map_err(|_| { + KesValidationError::Other("Invalid body signature".to_string()) + })?, + max_kes_evolutions, + )?; + Ok(()) + }), + Box::new(move || { + validate_operational_certificate( + cert, + &pool_id, + &issuer, + latest_sequence_number, + is_praos, + )?; + Ok(()) + }), + ], + pool_id, + declared_sequence_number, + )) +} + +fn operational_cert<'a>(header: &'a MultiEraHeader) -> Option> { + match header { + MultiEraHeader::ShelleyCompatible(x) => { + let cert = OperationalCertificate { + operational_cert_hot_vkey: &x.header_body.operational_cert_hot_vkey, + operational_cert_sequence_number: x.header_body.operational_cert_sequence_number, + operational_cert_kes_period: x.header_body.operational_cert_kes_period, + operational_cert_sigma: &x.header_body.operational_cert_sigma, + }; + Some(cert) + } + MultiEraHeader::BabbageCompatible(x) => Some(OperationalCertificate { + operational_cert_hot_vkey: &x.header_body.operational_cert.operational_cert_hot_vkey, + operational_cert_sequence_number: x + .header_body + .operational_cert + .operational_cert_sequence_number, + operational_cert_kes_period: x.header_body.operational_cert.operational_cert_kes_period, + operational_cert_sigma: &x.header_body.operational_cert.operational_cert_sigma, + }), + _ => None, + } +} + +fn body_signature<'a>(header: &'a MultiEraHeader) -> Option<&'a [u8]> { + match header { + MultiEraHeader::ShelleyCompatible(x) => Some(&x.body_signature), + MultiEraHeader::BabbageCompatible(x) => Some(&x.body_signature), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use acropolis_common::{genesis_values::GenesisValues, serialization::Bech32Conversion, Era}; + use pallas::ledger::traverse::MultiEraHeader; + + use super::*; + + #[test] + fn test_4490511_block_produced_by_genesis_key() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_4490511: Vec = + hex::decode(include_str!("./data/4490511.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Shelley as u8, None, &block_header_4490511).unwrap(); + + let ocert_counters = HashMap::new(); + let active_spos = HashSet::new(); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + assert!(result.is_ok()); + assert_eq!(result.unwrap().1, 0); + } + + #[test] + fn test_4556956_block() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_4556956: Vec = + hex::decode(include_str!("./data/4556956.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Shelley as u8, None, &block_header_4556956).unwrap(); + + let ocert_counters = HashMap::from_iter([( + PoolId::from_bech32("pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy") + .unwrap(), + 1, + )]); + let active_spos = HashSet::from_iter([PoolId::from_bech32( + "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy", + ) + .unwrap()]); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + assert!(result.is_ok()); + assert_eq!(result.unwrap().1, 1); + } + + #[test] + fn test_4556956_block_with_wrong_ocert_counter() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_4556956: Vec = + hex::decode(include_str!("./data/4556956.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Shelley as u8, None, &block_header_4556956).unwrap(); + + let ocert_counters = HashMap::from_iter([( + PoolId::from_bech32("pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy") + .unwrap(), + 2, + )]); + let active_spos = HashSet::from_iter([PoolId::from_bech32( + "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy", + ) + .unwrap()]); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Box::new(KesValidationError::OperationalCertificateError( + OperationalCertificateError::CounterTooSmallOcert { + latest_counter: 2, + declared_counter: 1 + } + )) + ); + } + + #[test] + fn test_4556956_block_with_missing_ocert_counter_and_active_spos() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_4556956: Vec = + hex::decode(include_str!("./data/4556956.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Shelley as u8, None, &block_header_4556956).unwrap(); + + let ocert_counters = HashMap::new(); + let active_spos = HashSet::new(); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Box::new(KesValidationError::NoOCertCounter { + pool_id: PoolId::from_bech32( + "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy" + ) + .unwrap(), + }) + ); + } + + #[test] + fn test_7854823_praos_block() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_7854823: Vec = + hex::decode(include_str!("./data/7854823.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Babbage as u8, None, &block_header_7854823).unwrap(); + + let ocert_counters = HashMap::from_iter([( + PoolId::from_bech32("pool195gdnmj6smzuakm4etxsxw3fgh8asqc4awtcskpyfnkpcvh2v8t") + .unwrap(), + 11, + )]); + let active_spos = HashSet::from_iter([PoolId::from_bech32( + "pool195gdnmj6smzuakm4etxsxw3fgh8asqc4awtcskpyfnkpcvh2v8t", + ) + .unwrap()]); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + assert!(result.is_ok()); + } + + #[test] + fn test_7854823_praos_block_with_overincremented_ocert_counter() { + let slots_per_kes_period = 129600; + let max_kes_evolutions = 62; + let genesis_values = GenesisValues::mainnet(); + + let block_header_7854823: Vec = + hex::decode(include_str!("./data/7854823.cbor")).unwrap(); + let block_header = + MultiEraHeader::decode(Era::Babbage as u8, None, &block_header_7854823).unwrap(); + + let ocert_counters = HashMap::from_iter([( + PoolId::from_bech32("pool195gdnmj6smzuakm4etxsxw3fgh8asqc4awtcskpyfnkpcvh2v8t") + .unwrap(), + // This is just for test case + // actual on-chain value is 11 + // now ocert counter is incremented by 2 + 9, + )]); + let active_spos = HashSet::from_iter([PoolId::from_bech32( + "pool195gdnmj6smzuakm4etxsxw3fgh8asqc4awtcskpyfnkpcvh2v8t", + ) + .unwrap()]); + + let result = validate_block_kes( + &block_header, + &ocert_counters, + &active_spos, + &genesis_values.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok((pool_id, declared_sequence_number)) + }); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + Box::new(KesValidationError::OperationalCertificateError( + OperationalCertificateError::CounterOverIncrementedOcert { + latest_counter: 9, + declared_counter: 11, + } + )) + ); + } +} diff --git a/modules/block_kes_validator/src/ouroboros/mod.rs b/modules/block_kes_validator/src/ouroboros/mod.rs new file mode 100644 index 00000000..1c6076b1 --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/mod.rs @@ -0,0 +1,4 @@ +pub mod kes; +pub mod kes_validation; +pub mod praos; +pub mod tpraos; diff --git a/modules/block_kes_validator/src/ouroboros/praos.rs b/modules/block_kes_validator/src/ouroboros/praos.rs new file mode 100644 index 00000000..5abce948 --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/praos.rs @@ -0,0 +1,16 @@ +use std::collections::HashSet; + +use acropolis_common::PoolId; +use imbl::HashMap; + +pub fn latest_issue_no_praos( + ocert_counters: &HashMap, + active_spos: &HashSet, + pool_id: &PoolId, +) -> Option { + ocert_counters.get(pool_id).copied().or(if active_spos.contains(pool_id) { + Some(0) + } else { + None + }) +} diff --git a/modules/block_kes_validator/src/ouroboros/tpraos.rs b/modules/block_kes_validator/src/ouroboros/tpraos.rs new file mode 100644 index 00000000..b7366a50 --- /dev/null +++ b/modules/block_kes_validator/src/ouroboros/tpraos.rs @@ -0,0 +1,23 @@ +use std::collections::HashSet; + +use acropolis_common::{GenesisDelegates, PoolId}; +use imbl::HashMap; + +/// This function is used to get the latest issue number for a given pool id. +/// First check ocert_counters +/// Check if the pool is in active_spos (registered or not) +/// And if the pool is a genesis delegate +/// Reference +/// https://github.com/IntersectMBO/cardano-ledger/blob/24ef1741c5e0109e4d73685a24d8e753e225656d/libs/cardano-protocol-tpraos/src/Cardano/Protocol/TPraos/OCert.hs#L66 +pub fn latest_issue_no_tpraos( + ocert_counters: &HashMap, + active_spos: &HashSet, + genesis_delegs: &GenesisDelegates, + pool_id: &PoolId, +) -> Option { + ocert_counters.get(pool_id).copied().or(if active_spos.contains(pool_id) { + Some(0) + } else { + genesis_delegs.as_ref().values().any(|v| v.delegate.eq(pool_id.as_ref())).then_some(0) + }) +} diff --git a/modules/block_kes_validator/src/state.rs b/modules/block_kes_validator/src/state.rs new file mode 100644 index 00000000..6eb8fc6a --- /dev/null +++ b/modules/block_kes_validator/src/state.rs @@ -0,0 +1,100 @@ +use std::collections::HashSet; + +use acropolis_common::{ + genesis_values::GenesisValues, + messages::{ProtocolParamsMessage, SPOStateMessage}, + validation::KesValidationError, + BlockInfo, PoolId, +}; +use imbl::HashMap; +use pallas::ledger::traverse::MultiEraHeader; +use tracing::error; + +use crate::ouroboros; + +#[derive(Default, Debug, Clone)] +pub struct State { + /// Tracks the latest operational certificate counter for each pool + pub ocert_counters: HashMap, + + pub slots_per_kes_period: Option, + + pub max_kes_evolutions: Option, + + pub active_spos: HashSet, +} + +impl State { + pub fn new() -> Self { + Self { + ocert_counters: HashMap::new(), + slots_per_kes_period: None, + max_kes_evolutions: None, + active_spos: HashSet::new(), + } + } + + pub fn handle_protocol_parameters(&mut self, msg: &ProtocolParamsMessage) { + if let Some(shelley_params) = msg.params.shelley.as_ref() { + self.slots_per_kes_period = Some(shelley_params.slots_per_kes_period as u64); + self.max_kes_evolutions = Some(shelley_params.max_kes_evolutions as u64); + } + } + + pub fn handle_spo_state(&mut self, msg: &SPOStateMessage) { + self.active_spos = msg.spos.iter().map(|spo| spo.operator).collect(); + } + + pub fn update_ocert_counter(&mut self, pool_id: PoolId, declared_sequence_number: u64) { + self.ocert_counters.insert(pool_id, declared_sequence_number); + } + + pub fn validate_block_kes( + &self, + block_info: &BlockInfo, + raw_header: &[u8], + genesis: &GenesisValues, + ) -> Result, Box> { + // Validation starts after Shelley Era + if block_info.epoch < genesis.shelley_epoch { + return Ok(None); + } + + let header = match MultiEraHeader::decode(block_info.era as u8, None, raw_header) { + Ok(header) => header, + Err(e) => { + error!("Can't decode header {}: {e}", block_info.slot); + return Err(Box::new(KesValidationError::Other(format!( + "Can't decode header {}: {e}", + block_info.slot + )))); + } + }; + + let Some(slots_per_kes_period) = self.slots_per_kes_period else { + return Err(Box::new(KesValidationError::Other( + "Slots per KES period is not set".to_string(), + ))); + }; + let Some(max_kes_evolutions) = self.max_kes_evolutions else { + return Err(Box::new(KesValidationError::Other( + "Max KES evolutions is not set".to_string(), + ))); + }; + + let result = ouroboros::kes_validation::validate_block_kes( + &header, + &self.ocert_counters, + &self.active_spos, + &genesis.genesis_delegs, + slots_per_kes_period, + max_kes_evolutions, + ) + .and_then(|(kes_validations, pool_id, declared_sequence_number)| { + kes_validations.iter().try_for_each(|assert| assert().map_err(Box::new))?; + Ok(Some((pool_id, declared_sequence_number))) + }); + + result + } +} diff --git a/modules/block_vrf_validator/src/block_vrf_validator.rs b/modules/block_vrf_validator/src/block_vrf_validator.rs index 69ecd04f..2f202293 100644 --- a/modules/block_vrf_validator/src/block_vrf_validator.rs +++ b/modules/block_vrf_validator/src/block_vrf_validator.rs @@ -27,12 +27,12 @@ const DEFAULT_BOOTSTRAPPED_SUBSCRIBE_TOPIC: (&str, &str) = ( "bootstrapped-subscribe-topic", "cardano.sequence.bootstrapped", ); +const DEFAULT_BLOCKS_SUBSCRIBE_TOPIC: (&str, &str) = + ("blocks-subscribe-topic", "cardano.block.proposed"); const DEFAULT_PROTOCOL_PARAMETERS_SUBSCRIBE_TOPIC: (&str, &str) = ( "protocol-parameters-subscribe-topic", "cardano.protocol.parameters", ); -const DEFAULT_BLOCKS_SUBSCRIBE_TOPIC: (&str, &str) = - ("blocks-subscribe-topic", "cardano.block.proposed"); const DEFAULT_EPOCH_ACTIVITY_SUBSCRIBE_TOPIC: (&str, &str) = ("epoch-activity-subscribe-topic", "cardano.epoch.activity"); const DEFAULT_SPO_STATE_SUBSCRIBE_TOPIC: (&str, &str) = diff --git a/processes/omnibus/Cargo.toml b/processes/omnibus/Cargo.toml index 89678f97..0c044896 100644 --- a/processes/omnibus/Cargo.toml +++ b/processes/omnibus/Cargo.toml @@ -31,6 +31,7 @@ acropolis_module_address_state = { path = "../../modules/address_state" } acropolis_module_consensus = { path = "../../modules/consensus" } acropolis_module_historical_accounts_state = { path = "../../modules/historical_accounts_state" } acropolis_module_block_vrf_validator = { path = "../../modules/block_vrf_validator" } +acropolis_module_block_kes_validator = { path = "../../modules/block_kes_validator" } caryatid_process = { workspace = true } caryatid_module_clock = { workspace = true } diff --git a/processes/omnibus/omnibus.toml b/processes/omnibus/omnibus.toml index 7de217e1..13173f8e 100644 --- a/processes/omnibus/omnibus.toml +++ b/processes/omnibus/omnibus.toml @@ -139,6 +139,8 @@ store-transactions = false [module.block-vrf-validator] +[module.block-kes-validator] + [module.clock] [module.rest-server] diff --git a/processes/omnibus/src/main.rs b/processes/omnibus/src/main.rs index a978dbc3..7498db7d 100644 --- a/processes/omnibus/src/main.rs +++ b/processes/omnibus/src/main.rs @@ -11,6 +11,7 @@ use tracing::info; use acropolis_module_accounts_state::AccountsState; use acropolis_module_address_state::AddressState; use acropolis_module_assets_state::AssetsState; +use acropolis_module_block_kes_validator::BlockKesValidator; use acropolis_module_block_unpacker::BlockUnpacker; use acropolis_module_block_vrf_validator::BlockVrfValidator; use acropolis_module_chain_store::ChainStore; @@ -121,6 +122,7 @@ pub async fn main() -> Result<()> { Consensus::register(&mut process); ChainStore::register(&mut process); BlockVrfValidator::register(&mut process); + BlockKesValidator::register(&mut process); Clock::::register(&mut process); RESTServer::::register(&mut process);