CVE-2026-34065
Description
nimiq-primitives contains primitives (e.g., block, account, transaction) to be used in Nimiq's Rust implementation. Prior to version 1.3.0, an untrusted p2p peer can cause a node to panic by announcing an election macro block whose validators set contains an invalid compressed BLS voting key. Hashing an election macro header hashes validators and reaches Validators::voting_keys(), which calls validator.voting_key.uncompress().unwrap() and panics on invalid bytes. The patch for this vulnerability is included as part of v1.3.0. No known workarounds are available.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nimiq-primitivescrates.io | <= 0.2.0 | — |
Affected products
1Patches
1e10eaebcd777Fix crash via invalid election macro block validators voting key
7 files changed · +133 −16
consensus/src/sync/live/block_queue/queue.rs+10 −0 modified@@ -160,6 +160,16 @@ impl<N: Network> BlockQueue<N> { let blockchain = self.blockchain.read(); + // Validate header before hashing to reject malformed blocks (e.g. invalid BLS keys) + // that would panic during hash computation. + if block + .verify_header(blockchain.network_id(), block.is_skip()) + .is_err() + { + block_source.reject_block(&self.network); + return None; + } + let block_hash = block.hash_cached(); let block_number = block.block_number(); let head_height = blockchain.block_number();
genesis-builder/src/lib.rs+6 −1 modified@@ -663,7 +663,12 @@ impl GenesisBuilder { { let decompressed_path = directory.join("decompressed_keys.dat"); let mut decompressed = Vec::new(); - for key in block.validators().expect("must be election").voting_keys() { + for key in block + .validators() + .expect("must be election") + .voting_keys() + .expect("genesis validators must have valid keys") + { decompressed.extend_from_slice(&key.trusted_serialize()); } fs::write(decompressed_path, decompressed)?;
primitives/block/src/equivocation_proof.rs+6 −3 modified@@ -558,12 +558,15 @@ impl DoubleVoteProof { // Verify the signatures. let mut agg_pk = AggregatePublicKey::new(); for slot in signers { - let pk = validators + if let Some(pk) = validators .get_validator_by_slot_number(*slot) .voting_key .uncompress() - .expect("Failed to uncompress CompressedPublicKey"); - agg_pk.aggregate(pk); + { + agg_pk.aggregate(pk); + } else { + return Err(EquivocationProofError::InvalidJustification); + } } if !agg_pk.verify(&message, signature) { return Err(EquivocationProofError::InvalidJustification);
primitives/block/src/macro_block.rs+49 −1 modified@@ -244,6 +244,12 @@ impl MacroHeader { if self.is_election() != self.validators.is_some() { return Err(BlockError::InvalidValidators); } + // Validate that all BLS voting keys can be decompressed and structural invariants hold. + if let Some(ref validators) = self.validators { + validators + .validate_keys() + .map_err(|_| BlockError::InvalidValidators)?; + } Ok(()) } @@ -394,7 +400,14 @@ pub enum IntoSlotsError { #[cfg(test)] mod test { - use super::MacroBlock; + use nimiq_bls::CompressedPublicKey as BlsCompressedPublicKey; + use nimiq_keys::Ed25519PublicKey as SchnorrPublicKey; + use nimiq_primitives::{ + policy::Policy, + slots_allocation::{Validator, Validators}, + }; + + use super::{MacroBlock, MacroHeader}; #[test] fn size_well_below_msg_limit() { @@ -404,4 +417,39 @@ mod test { <= dbg!(nimiq_network_interface::network::MIN_SUPPORTED_MSG_SIZE) ); } + + /// Verifies that invalid BLS voting keys are caught by verify() and + /// voting_keys(), preventing the panic that would occur in hash_cached(). + /// + /// Regression test for: untrusted peer announces an election macro block + /// with an invalid BLS voting key. Before the fix, `hash_cached()` would + /// panic via `voting_keys()` → `uncompress().unwrap()`. + #[test] + fn invalid_bls_voting_key_rejected_by_verify() { + let invalid_compressed = BlsCompressedPublicKey { + public_key: [0xFF; BlsCompressedPublicKey::SIZE], + }; + + let validator = Validator::new( + nimiq_keys::Address::default(), + invalid_compressed, + SchnorrPublicKey::default(), + 0..Policy::SLOTS, + ); + + let validators = Validators::new(vec![validator]); + + // validate_keys() catches the invalid key. + assert!(validators.validate_keys().is_err()); + + // voting_keys() returns an error instead of panicking. + assert!(validators.voting_keys().is_err()); + + // MacroHeader::verify() rejects the block. + let header = MacroHeader { + validators: Some(validators), + ..Default::default() + }; + assert!(header.verify().is_err()); + } }
primitives/block/src/tendermint.rs+6 −3 modified@@ -68,12 +68,15 @@ impl TendermintProof { let mut agg_pk = AggregatePublicKey::new(); for slot in signer_slots { - let pk = current_validators + if let Some(pk) = current_validators .get_validator_by_slot_number(slot) .voting_key .uncompress() - .expect("Failed to uncompress CompressedPublicKey"); - agg_pk.aggregate(pk); + { + agg_pk.aggregate(pk); + } else { + return false; + } } // Verify the aggregated signature against our aggregated public key.
primitives/src/slots_allocation.rs+53 −7 modified@@ -14,7 +14,7 @@ //! | SlotBand | SlotBand | //! +-------------------------------------------+-------------------+ //! ``` -use std::{cmp::Ordering, collections::BTreeMap, ops::Range, slice::Iter}; +use std::{cmp::Ordering, collections::BTreeMap, fmt, ops::Range, slice::Iter}; use ark_ec::CurveGroup; use ark_serialize::CanonicalSerialize; @@ -26,6 +26,18 @@ use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use crate::{merkle_tree::merkle_tree_construct, policy::Policy}; +/// Error returned when a validator has an invalid BLS voting key that cannot be decompressed. +#[derive(Clone, Debug)] +pub struct InvalidValidatorsError; + +impl fmt::Display for InvalidValidatorsError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid BLS voting key for validator") + } +} + +impl std::error::Error for InvalidValidatorsError {} + /// This is the depth of the PKTree circuit. pub const PK_TREE_DEPTH: usize = 5; /// This is the number of leaves in the PKTree circuit. @@ -187,20 +199,52 @@ impl Validators { } /// Returns the G2 projective associated with each slot, in order. - pub fn voting_keys_g2(&self) -> Vec<G2Projective> { - self.voting_keys().iter().map(|pk| pk.public_key).collect() + pub fn voting_keys_g2(&self) -> Result<Vec<G2Projective>, InvalidValidatorsError> { + Ok(self.voting_keys()?.iter().map(|pk| pk.public_key).collect()) } /// Returns the voting key associated with each slot, in order. - pub fn voting_keys(&self) -> Vec<BlsPublicKey> { + pub fn voting_keys(&self) -> Result<Vec<BlsPublicKey>, InvalidValidatorsError> { let mut pks = vec![]; for validator in self.iter() { - let pk = *validator.voting_key.uncompress().unwrap(); + let pk = *validator + .voting_key + .uncompress() + .ok_or(InvalidValidatorsError)?; pks.append(&mut vec![pk; validator.num_slots() as usize]); } - pks + Ok(pks) + } + + /// Checks that all validator voting keys can be decompressed and that the + /// validator set structure is valid (correct number of slots, compatible with ZK circuit). + pub fn validate_keys(&self) -> Result<(), InvalidValidatorsError> { + let mut total_slots: u16 = 0; + + // Check that all keys can be decompressed and count total slots in one pass + for validator in self.iter() { + validator + .voting_key + .uncompress() + .ok_or(InvalidValidatorsError)?; + total_slots += validator.num_slots(); + } + + // Check structural invariants that would cause panics in Hash implementation + + // Must have exactly Policy::SLOTS total slots + if total_slots != Policy::SLOTS { + return Err(InvalidValidatorsError); + } + + // Must be a multiple of PK_TREE_BREADTH for ZK circuit compatibility + if !(total_slots as usize).is_multiple_of(PK_TREE_BREADTH) { + return Err(InvalidValidatorsError); + } + + Ok(()) } /// Iterates over the validators. @@ -213,7 +257,9 @@ impl Hash for Validators { /// This function is meant to calculate the public key tree "off-circuit". Generating the public key /// tree with this function guarantees that it is compatible with the ZK circuit. fn hash<H: HashOutput>(&self) -> H { - let public_keys = self.voting_keys_g2(); + let public_keys = self.voting_keys_g2().expect( + "BLS voting keys must be valid — call validate_keys() before hashing untrusted data", + ); // Checking that the number of public keys is equal to the number of validator slots. assert_eq!(public_keys.len(), Policy::SLOTS as usize);
zkp/src/proof_system/prove.rs+3 −1 modified@@ -668,7 +668,9 @@ fn prove_macro_block<R: CryptoRng + Rng>( let prev_validators = prev_block .get_validators() .ok_or(NanoZKPError::InvalidBlock)?; - let prev_pks = prev_validators.voting_keys_g2(); + let prev_pks = prev_validators + .voting_keys_g2() + .map_err(|_| NanoZKPError::InvalidBlock)?; // Calculate final public key tree root. let signer_bitmap: Vec<bool> = final_block
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/nimiq/core-rs-albatross/commit/e10eaebcd7774e5da6d0ff5e88ed13503474f0ffnvdPatchWEB
- github.com/nimiq/core-rs-albatross/pull/3662nvdIssue TrackingPatchWEB
- github.com/nimiq/core-rs-albatross/security/advisories/GHSA-7c4j-2m43-2mghnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-7c4j-2m43-2mghghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-34065ghsaADVISORY
- github.com/nimiq/core-rs-albatross/releases/tag/v1.3.0nvdRelease NotesWEB
News mentions
0No linked articles in our index yet.