From d1a5cab9555656abd12cce37458b8c2575a8e9c0 Mon Sep 17 00:00:00 2001 From: Marvy247 Date: Thu, 8 Jan 2026 15:05:45 +0100 Subject: [PATCH] feat: implement validators for Stacks signer message predicates - Add validation for StacksSignerMessagePredicate with pubkey and timestamp checks - Validate secp256k1 public keys (compressed 33 bytes, uncompressed 65 bytes) - Validate timestamps are non-zero and before year 2100 - Implement pubkey evaluation in evaluate_stacks_predicate_on_non_consensus_events - Add comprehensive test suite covering valid/invalid pubkeys and timestamps - Resolve TODOs for signer message predicate validation and evaluation This change improves security by preventing invalid signer public keys and timestamps from entering the system during predicate validation. --- .../src/chainhooks/stacks/mod.rs | 107 ++++++++++++++++-- .../stacks/tests/hook_spec_validation.rs | 73 ++++++++++++ 2 files changed, 170 insertions(+), 10 deletions(-) diff --git a/components/chainhook-sdk/src/chainhooks/stacks/mod.rs b/components/chainhook-sdk/src/chainhooks/stacks/mod.rs index 36995f1a4..a37a99923 100644 --- a/components/chainhook-sdk/src/chainhooks/stacks/mod.rs +++ b/components/chainhook-sdk/src/chainhooks/stacks/mod.rs @@ -3,13 +3,13 @@ use crate::utils::{AbstractStacksBlock, Context, MAX_BLOCK_HEIGHTS_ENTRIES}; use super::types::validate_txid; use super::types::{ - append_error_context, BlockIdentifierIndexRule, ChainhookInstance, ExactMatchingRule, + append_error_context, is_hex, BlockIdentifierIndexRule, ChainhookInstance, ExactMatchingRule, HookAction, }; use chainhook_types::{ BlockIdentifier, StacksChainEvent, StacksNetwork, StacksNonConsensusEventData, - StacksTransactionData, StacksTransactionEvent, StacksTransactionEventPayload, - StacksTransactionKind, TransactionIdentifier, + StacksNonConsensusEventPayloadData, StacksTransactionData, StacksTransactionEvent, + StacksTransactionEventPayload, StacksTransactionKind, TransactionIdentifier, }; use clarity::codec::StacksMessageCodec; use clarity::vm::types::{ @@ -310,11 +310,14 @@ impl StacksPredicate { } } #[cfg(feature = "stacks-signers")] - StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(_)) => { - // TODO(rafaelcr): Validate pubkey format + StacksPredicate::SignerMessage(predicate) => { + if let Err(e) = predicate.validate() { + return Err(append_error_context( + "invalid predicate for scope 'signer_message'", + vec![e], + )); + } } - #[cfg(feature = "stacks-signers")] - StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(_)) => {} } Ok(()) } @@ -328,7 +331,73 @@ pub enum StacksSignerMessagePredicate { } impl StacksSignerMessagePredicate { - // TODO(rafaelcr): Write validators + pub fn validate(&self) -> Result<(), String> { + match self { + StacksSignerMessagePredicate::AfterTimestamp(timestamp) => { + validate_timestamp(*timestamp) + } + StacksSignerMessagePredicate::FromSignerPubKey(pubkey) => { + validate_signer_pubkey(pubkey) + } + } + } +} + +fn validate_timestamp(timestamp: u64) -> Result<(), String> { + if timestamp == 0 { + return Err("timestamp must be greater than 0".into()); + } + // Check for unreasonably far future timestamps (year 2100) + const YEAR_2100_TIMESTAMP: u64 = 4102444800000; // milliseconds + if timestamp > YEAR_2100_TIMESTAMP { + return Err("timestamp must be a reasonable Unix timestamp in milliseconds (before year 2100)".into()); + } + Ok(()) +} + +fn validate_signer_pubkey(pubkey: &String) -> Result<(), String> { + // Remove 0x prefix if present + let pubkey_hex = if pubkey.starts_with("0x") || pubkey.starts_with("0X") { + &pubkey[2..] + } else { + pubkey.as_str() + }; + + // Check if it's valid hex + if !is_hex(pubkey_hex) { + return Err("signer public key must be a hexadecimal string".into()); + } + + // secp256k1 public keys can be: + // - Compressed: 33 bytes (66 hex characters) + // - Uncompressed: 65 bytes (130 hex characters) + let len = pubkey_hex.len(); + + // Validate compressed key (66 hex characters) + if len == 66 { + let prefix = &pubkey_hex[0..2]; + if prefix != "02" && prefix != "03" { + return Err( + "compressed signer public key must start with '02' or '03'".into(), + ); + } + return Ok(()); + } + + // Validate uncompressed key (130 hex characters) + if len == 130 { + let prefix = &pubkey_hex[0..2]; + if prefix != "04" { + return Err("uncompressed signer public key must start with '04'".into()); + } + return Ok(()); + } + + // If we reach here, the length is invalid + Err( + "signer public key must be a valid secp256k1 public key (33 bytes compressed or 65 bytes uncompressed), represented as a hexadecimal string" + .into(), + ) } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] @@ -863,8 +932,26 @@ pub fn evaluate_stacks_predicate_on_non_consensus_events<'a>( occurrences.push(event); } } - StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(_)) => { - // TODO(rafaelcr): Evaluate on pubkey + StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey( + expected_pubkey, + )) => { + let StacksNonConsensusEventPayloadData::SignerMessage(chunk) = &event.payload; + // Normalize both pubkeys by removing "0x" prefix if present for comparison + let normalized_expected = if expected_pubkey.starts_with("0x") || expected_pubkey.starts_with("0X") { + &expected_pubkey[2..] + } else { + expected_pubkey.as_str() + }; + + let normalized_actual = if chunk.pubkey.starts_with("0x") || chunk.pubkey.starts_with("0X") { + &chunk.pubkey[2..] + } else { + chunk.pubkey.as_str() + }; + + if normalized_expected.eq_ignore_ascii_case(normalized_actual) { + occurrences.push(event); + } } StacksPredicate::BlockHeight(_) | StacksPredicate::ContractDeployment(_) diff --git a/components/chainhook-sdk/src/chainhooks/stacks/tests/hook_spec_validation.rs b/components/chainhook-sdk/src/chainhooks/stacks/tests/hook_spec_validation.rs index e0ea08259..8813d3c1b 100644 --- a/components/chainhook-sdk/src/chainhooks/stacks/tests/hook_spec_validation.rs +++ b/components/chainhook-sdk/src/chainhooks/stacks/tests/hook_spec_validation.rs @@ -1,5 +1,7 @@ use std::collections::BTreeMap; use crate::chainhooks::stacks::{StacksChainhookSpecification, StacksChainhookSpecificationNetworkMap, StacksContractCallBasedPredicate, StacksContractDeploymentPredicate, StacksPredicate, StacksPrintEventBasedPredicate}; +#[cfg(feature = "stacks-signers")] +use crate::chainhooks::stacks::StacksSignerMessagePredicate; use crate::chainhooks::types::*; use crate::chainhooks::types::HttpHook; use chainhook_types::StacksNetwork; @@ -33,6 +35,25 @@ lazy_static! { static ref PRINT_EVENT_ID_ERR: String = "invalid predicate for scope 'print_event': invalid contract identifier: ParseError(\"Invalid principal literal: base58ck checksum 0x147e6835 does not match expected 0x9b3dfe6a\")".into(); static ref INVALID_REGEX_ERR: String = "invalid predicate for scope 'print_event': invalid regex: regex parse error:\n [\\]\n ^\nerror: unclosed character class".into(); + // Signer message predicates (secp256k1 pubkeys: compressed=66 hex chars, uncompressed=130 hex chars) + static ref COMPRESSED_PUBKEY_VALID_WITH_PREFIX: String = "0x02a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into(); + static ref COMPRESSED_PUBKEY_VALID_NO_PREFIX: String = "02a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into(); + static ref UNCOMPRESSED_PUBKEY_VALID: String = "0x04a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890ab123456789012345678901234567890123456789012345678901234567890".into(); + static ref PUBKEY_INVALID_LENGTH: String = "0x02a1b2c3d4".into(); + static ref PUBKEY_INVALID_HEX: String = "0x02g1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into(); + static ref PUBKEY_INVALID_COMPRESSED_PREFIX: String = "0x01a1b2c3d4e5f67890123456789012345678901234567890123456789012345678".into(); + static ref PUBKEY_INVALID_UNCOMPRESSED_PREFIX: String = "0x05a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890ab123456789012345678901234567890123456789012345678901234567890".into(); + static ref TIMESTAMP_VALID: u64 = 1704067200000; // Jan 1, 2024 in milliseconds + static ref TIMESTAMP_ZERO: u64 = 0; + static ref TIMESTAMP_TOO_FAR_FUTURE: u64 = 5000000000000; // Beyond year 2100 + + static ref SIGNER_PUBKEY_LENGTH_ERR: String = "invalid predicate for scope 'signer_message': signer public key must be a valid secp256k1 public key (33 bytes compressed or 65 bytes uncompressed), represented as a hexadecimal string".into(); + static ref SIGNER_PUBKEY_HEX_ERR: String = "invalid predicate for scope 'signer_message': signer public key must be a hexadecimal string".into(); + static ref SIGNER_PUBKEY_COMPRESSED_PREFIX_ERR: String = "invalid predicate for scope 'signer_message': compressed signer public key must start with '02' or '03'".into(); + static ref SIGNER_PUBKEY_UNCOMPRESSED_PREFIX_ERR: String = "invalid predicate for scope 'signer_message': uncompressed signer public key must start with '04'".into(); + static ref SIGNER_TIMESTAMP_ZERO_ERR: String = "invalid predicate for scope 'signer_message': timestamp must be greater than 0".into(); + static ref SIGNER_TIMESTAMP_FAR_FUTURE_ERR: String = "invalid predicate for scope 'signer_message': timestamp must be a reasonable Unix timestamp in milliseconds (before year 2100)".into(); + static ref INVALID_PREDICATE: StacksPredicate = StacksPredicate::PrintEvent(StacksPrintEventBasedPredicate::MatchesRegex { contract_identifier: CONTRACT_ID_INVALID_ADDRESS.clone(), regex: INVALID_REGEX.clone() }); static ref INVALID_HOOK_ACTION: HookAction = HookAction::HttpPost(HttpHook { url: "".into(), authorization_header: "\n".into() }); @@ -177,6 +198,58 @@ lazy_static! { &StacksPredicate::Txid(ExactMatchingRule::Equals(TXID_VALID.clone())), None; "txid just right" )] +// StacksPredicate::SignerMessage - Pubkey validation +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(COMPRESSED_PUBKEY_VALID_WITH_PREFIX.clone())), + None; "signer pubkey compressed with prefix" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(COMPRESSED_PUBKEY_VALID_NO_PREFIX.clone())), + None; "signer pubkey compressed no prefix" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(UNCOMPRESSED_PUBKEY_VALID.clone())), + None; "signer pubkey uncompressed valid" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_LENGTH.clone())), + Some(vec![SIGNER_PUBKEY_LENGTH_ERR.clone()]); "signer pubkey invalid length" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_HEX.clone())), + Some(vec![SIGNER_PUBKEY_HEX_ERR.clone()]); "signer pubkey invalid hex" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_COMPRESSED_PREFIX.clone())), + Some(vec![SIGNER_PUBKEY_COMPRESSED_PREFIX_ERR.clone()]); "signer pubkey invalid compressed prefix" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::FromSignerPubKey(PUBKEY_INVALID_UNCOMPRESSED_PREFIX.clone())), + Some(vec![SIGNER_PUBKEY_UNCOMPRESSED_PREFIX_ERR.clone()]); "signer pubkey invalid uncompressed prefix" +)] +// StacksPredicate::SignerMessage - Timestamp validation +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_VALID)), + None; "signer timestamp valid" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_ZERO)), + Some(vec![SIGNER_TIMESTAMP_ZERO_ERR.clone()]); "signer timestamp zero" +)] +#[cfg(feature = "stacks-signers")] +#[test_case( + &StacksPredicate::SignerMessage(StacksSignerMessagePredicate::AfterTimestamp(*TIMESTAMP_TOO_FAR_FUTURE)), + Some(vec![SIGNER_TIMESTAMP_FAR_FUTURE_ERR.clone()]); "signer timestamp too far future" +)] fn it_validates_stacks_predicates(predicate: &StacksPredicate, expected_err: Option>) { if let Err(e) = predicate.validate() { if let Some(expected) = expected_err {