From 16fb6ac2bd71198d2ed0b094f74f31bc998b1e87 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Mon, 27 Jul 2026 19:59:03 +0530 Subject: [PATCH 1/2] fix: dynamic profile-based budgets and typed error returns for loop prevention --- src/shrincs/shrincs_signer_fors_c.rs | 6 ++-- src/shrincs/shrincs_signer_hypertree.rs | 10 +++---- src/shrincs/shrincs_signer_stateful.rs | 18 ++++++------ src/shrincs/shrincs_signer_types.rs | 16 +++++++++-- src/shrincs/shrincs_signer_utils.rs | 3 ++ src/shrincs/signer.rs | 18 ++++++------ src/wasm/mod.rs | 38 +++++++++++++++++-------- 7 files changed, 68 insertions(+), 41 deletions(-) diff --git a/src/shrincs/shrincs_signer_fors_c.rs b/src/shrincs/shrincs_signer_fors_c.rs index dab312a..96e149c 100644 --- a/src/shrincs/shrincs_signer_fors_c.rs +++ b/src/shrincs/shrincs_signer_fors_c.rs @@ -24,7 +24,7 @@ use zeroize::Zeroizing; -use super::shrincs_signer_types::{ShrincsSignerResult, ShrincsSigningKey}; +use super::shrincs_signer_types::{ShrincsSignerError, ShrincsSignerResult, ShrincsSigningKey}; use super::shrincs_signer_utils::{ fors_address_word, hash_node, hash_packed, pack, read_bits32, read_bits64, FORS_C_MAX_GRIND_COUNTER, @@ -109,7 +109,7 @@ pub(crate) fn sign_fors_c( // The verifier aggregates the reconstructed per-tree roots the same way. // The public seed is included so roots from a different FORS key cannot // be transplanted into this key. - return Some(SignedForsC { + return Ok(SignedForsC { root: hash_node(&[b"fors-pk", &signing_key.pk_seed, &roots]), signature: ForsSignature { randomizer: randomizer.to_vec(), @@ -121,7 +121,7 @@ pub(crate) fn sign_fors_c( }); } - None + Err(ShrincsSignerError::GrindBudgetExhausted) } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/shrincs/shrincs_signer_hypertree.rs b/src/shrincs/shrincs_signer_hypertree.rs index 467b1a0..c072b9a 100644 --- a/src/shrincs/shrincs_signer_hypertree.rs +++ b/src/shrincs/shrincs_signer_hypertree.rs @@ -19,7 +19,7 @@ use zeroize::Zeroizing; -use super::shrincs_signer_types::{ShrincsSignerResult, ShrincsSigningKey}; +use super::shrincs_signer_types::{ShrincsSignerError, ShrincsSignerResult, ShrincsSigningKey}; use super::shrincs_signer_utils::{ address_word32, base_w_digit, derive32, hash_node, hash_packed, hypertree_address_word, wots_digest_bytes, WOTS_C_MAX_GRIND_COUNTER, @@ -84,7 +84,7 @@ pub(crate) fn sign_hypertree( // Mirror the verifier's guard so a retuned profile fails closed instead of // panicking on the shift below. if subtree_height == 0 || subtree_height >= u32::BITS { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } let leaf_mask = (1u64 << subtree_height) - 1; // `stateless_sk_seed` is the shared SK.seed-style master for FORS-C and @@ -166,7 +166,7 @@ pub(crate) fn sign_hypertree( leaf = (tree & leaf_mask) as u32; tree >>= subtree_height; } - Some(layers) + Ok(layers) } pub(crate) fn hypertree_public_root( @@ -337,14 +337,14 @@ fn sign_stateless_wots_c( .to_vec() }) .collect(); - return Some(WotsCSignature { + return Ok(WotsCSignature { randomizer: randomizer.to_vec(), counter, chains, }); } - None + Err(ShrincsSignerError::GrindBudgetExhausted) } fn stateless_wots_c_secret(sk_seed: &[u8; HASH_LEN], chain: u32) -> [u8; HASH_LEN] { diff --git a/src/shrincs/shrincs_signer_stateful.rs b/src/shrincs/shrincs_signer_stateful.rs index 10eb1e2..c2e7011 100644 --- a/src/shrincs/shrincs_signer_stateful.rs +++ b/src/shrincs/shrincs_signer_stateful.rs @@ -19,7 +19,7 @@ use zeroize::Zeroizing; -use super::shrincs_signer_types::{ShrincsSignerResult, ShrincsSigningKey}; +use super::shrincs_signer_types::{ShrincsSignerError, ShrincsSignerResult, ShrincsSigningKey}; use super::shrincs_signer_utils::{ address_word32, base_w16_digit, hash_node, hash_packed, WOTS_C_MAX_GRIND_COUNTER, }; @@ -36,10 +36,10 @@ pub(crate) fn sign_stateful_raw( // signer must advance one leaf at a time and must never reuse a prior leaf. let leaf_index = signing_key.next_stateful_leaf_index; if leaf_index == 0 { - return None; + return Err(ShrincsSignerError::StatefulLeavesExhausted); } if leaf_index > signing_key.max_stateful_signatures { - return None; + return Err(ShrincsSignerError::StatefulLeavesExhausted); } // sign_stateful_raw_at_leaf already computes the identical auth_path (same @@ -48,7 +48,7 @@ pub(crate) fn sign_stateful_raw( // the dominant signing cost. let signature = sign_stateful_raw_at_leaf(signing_key, leaf_index, message)?; signing_key.next_stateful_leaf_index = leaf_index.saturating_add(1); - Some(signature) + Ok(signature) } pub(crate) fn sign_stateful_raw_at_leaf( @@ -60,10 +60,10 @@ pub(crate) fn sign_stateful_raw_at_leaf( // Production signing should use `sign_stateful_raw`, which advances the // monotonic `next_stateful_leaf_index` and avoids accidental leaf reuse. if leaf_index == 0 { - return None; + return Err(ShrincsSignerError::StatefulLeavesExhausted); } if leaf_index > signing_key.max_stateful_signatures { - return None; + return Err(ShrincsSignerError::StatefulLeavesExhausted); } let mut signature = sign_stateful_wots_c( &signing_key.stateful_sk_seed, @@ -78,7 +78,7 @@ pub(crate) fn sign_stateful_raw_at_leaf( leaf_index, signing_key.max_stateful_signatures, ); - Some(signature) + Ok(signature) } pub(crate) fn stateful_subtree_root( @@ -155,7 +155,7 @@ fn sign_stateful_wots_c( }) .collect(); - return Some(StatefulSignature { + return Ok(StatefulSignature { randomizer, counter, chains, @@ -163,7 +163,7 @@ fn sign_stateful_wots_c( }); } - None + Err(ShrincsSignerError::GrindBudgetExhausted) } fn stateful_chain_secret( diff --git a/src/shrincs/shrincs_signer_types.rs b/src/shrincs/shrincs_signer_types.rs index 2b40e46..12771d1 100644 --- a/src/shrincs/shrincs_signer_types.rs +++ b/src/shrincs/shrincs_signer_types.rs @@ -22,9 +22,19 @@ use core::fmt; use super::verifier::HASH_LEN; use zeroize::{Zeroize, ZeroizeOnDrop}; -/// Signer operations return `None` when stateful leaves are exhausted or -/// WOTS-C/FORS-C grinding fails within the configured counter budget. -pub type ShrincsSignerResult = Option; +/// Typed errors returned by SHRINCS signer operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ShrincsSignerError { + #[error("stateful leaves out of range or budget exhausted")] + StatefulLeavesExhausted, + #[error("grind counter budget exhausted for the supplied key and message")] + GrindBudgetExhausted, + #[error("invalid parameter or key configuration")] + InvalidConfiguration, +} + +/// Signer operations return `Result`. +pub type ShrincsSignerResult = Result; /// Secret material for both the stateful fast path and stateless recovery path. /// diff --git a/src/shrincs/shrincs_signer_utils.rs b/src/shrincs/shrincs_signer_utils.rs index 5538260..18ba6c8 100644 --- a/src/shrincs/shrincs_signer_utils.rs +++ b/src/shrincs/shrincs_signer_utils.rs @@ -33,6 +33,9 @@ pub(crate) use super::super::shrincs_common::{ use super::verifier::{PublicKey, HASH_LEN, PROFILE_NAME, STATEFUL_PUBLIC_KEY_BYTES}; pub(crate) const WOTS_C_MAX_GRIND_COUNTER: u32 = 1 << 24; +#[cfg(any(feature = "profile-128s-q18", feature = "profile-128s-q20"))] +pub(crate) const FORS_C_MAX_GRIND_COUNTER: u32 = 1 << 30; +#[cfg(not(any(feature = "profile-128s-q18", feature = "profile-128s-q20")))] pub(crate) const FORS_C_MAX_GRIND_COUNTER: u32 = 1 << 24; pub(crate) fn public_key_from_components( diff --git a/src/shrincs/signer.rs b/src/shrincs/signer.rs index 127763e..f457038 100644 --- a/src/shrincs/signer.rs +++ b/src/shrincs/signer.rs @@ -34,7 +34,7 @@ mod shrincs_signer_types; #[path = "shrincs_signer_utils.rs"] mod shrincs_signer_utils; -pub use self::shrincs_signer_types::{ShrincsSignerResult, ShrincsSigningKey}; +pub use self::shrincs_signer_types::{ShrincsSignerError, ShrincsSignerResult, ShrincsSigningKey}; use self::shrincs_signer_fors_c::sign_fors_c; use self::shrincs_signer_hypertree::{hypertree_public_root, sign_hypertree}; @@ -66,10 +66,10 @@ impl ShrincsSigner { max_stateful_signatures: u32, ) -> ShrincsSignerResult<(ShrincsSigningKey, PublicKey)> { if max_stateful_signatures == 0 { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } if max_stateful_signatures > MAX_STATEFUL_SIGNATURES_LIMIT { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } let stateful_sk_seed = derive32(b"shrincs-stateful-sk-seed", seed_material, &[]); @@ -104,7 +104,7 @@ impl ShrincsSigner { hypertree_root, ); - Some((signing_key, public_key)) + Ok((signing_key, public_key)) } /// Reconstruct a signing key from previously exported fields (the inverse @@ -119,11 +119,11 @@ impl ShrincsSigner { ) -> ShrincsSignerResult<(ShrincsSigningKey, PublicKey)> { let max = candidate.max_stateful_signatures; if max == 0 || max > MAX_STATEFUL_SIGNATURES_LIMIT { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } let next = candidate.next_stateful_leaf_index; if next < INITIAL_STATEFUL_LEAF_INDEX || next > max.saturating_add(1) { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } // Recompute, never trust: the roots are consensus-critical inputs to // every signature this key will produce. The stateful root always @@ -137,14 +137,14 @@ impl ShrincsSigner { let hypertree_root = hypertree_public_root(&candidate.stateless_sk_seed, &candidate.pk_seed); if stateful_root != candidate.stateful_root || hypertree_root != candidate.hypertree_root { - return None; + return Err(ShrincsSignerError::InvalidConfiguration); } let public_key = public_key_from_components( encode_stateful_public_key(candidate.stateful_pk_seed, stateful_root, max), candidate.pk_seed, hypertree_root, ); - Some((candidate, public_key)) + Ok((candidate, public_key)) } /// Sign the verifier's canonical stateful action hash and advance the leaf counter. @@ -194,7 +194,7 @@ impl ShrincsSigner { signed_fors.tree_index, signed_fors.leaf_index, )?; - Some(StatelessSignature { + Ok(StatelessSignature { fors: signed_fors.signature, hypertree, }) diff --git a/src/wasm/mod.rs b/src/wasm/mod.rs index 169565b..d352a8c 100644 --- a/src/wasm/mod.rs +++ b/src/wasm/mod.rs @@ -76,6 +76,8 @@ const ERR_STATEFUL_LEAF_REJECTED: &str = "ERR_STATEFUL_LEAF_REJECTED"; #[cfg(feature = "wasm-bindings")] const ERR_SIGNING_FAILED: &str = "ERR_SIGNING_FAILED"; #[cfg(feature = "wasm-bindings")] +const ERR_GRIND_BUDGET_EXHAUSTED: &str = "ERR_GRIND_BUDGET_EXHAUSTED"; +#[cfg(feature = "wasm-bindings")] const ERR_KEYGEN_FAILED: &str = "ERR_KEYGEN_FAILED"; #[cfg(feature = "wasm-bindings")] const ERR_INVALID_INPUT: &str = "ERR_INVALID_INPUT"; @@ -128,7 +130,7 @@ export type ShrincsErrorCode = | "ERR_LEAF_OUT_OF_RANGE" | "ERR_INVALID_SIGNATURE" | "ERR_BUDGET_EXHAUSTED" | "ERR_RECOVERY_NOT_ARMED" | "ERR_STATEFUL_PATH_DISABLED" - | "ERR_STATEFUL_LEAF_REJECTED"; + | "ERR_STATEFUL_LEAF_REJECTED" | "ERR_GRIND_BUDGET_EXHAUSTED"; "#; #[cfg(feature = "wasm-bindings")] @@ -200,11 +202,15 @@ impl WasmShrincsKeypair { })); } let signature = - ShrincsSigner::sign_stateful_raw(signing_key, &message).ok_or_else(|| { - js_error(WasmErr { + ShrincsSigner::sign_stateful_raw(signing_key, &message).map_err(|err| match err { + crate::shrincs::ShrincsSignerError::GrindBudgetExhausted => js_error(WasmErr { + code: ERR_GRIND_BUDGET_EXHAUSTED, + message: "grind counter budget exhausted for the supplied key/message".into(), + }), + _ => js_error(WasmErr { code: ERR_SIGNING_FAILED, message: "stateful signing failed for the supplied key/message".into(), - }) + }), })?; js_value_from_serde(&StatefulSignResult { signature: stateful_signature_dto_from_signer(&signature), @@ -236,11 +242,15 @@ impl WasmShrincsKeypair { })); } let signature = ShrincsSigner::sign_stateful_raw_at_leaf(signing_key, leaf, &message) - .ok_or_else(|| { - js_error(WasmErr { + .map_err(|err| match err { + crate::shrincs::ShrincsSignerError::GrindBudgetExhausted => js_error(WasmErr { + code: ERR_GRIND_BUDGET_EXHAUSTED, + message: "grind counter budget exhausted for the supplied key/message".into(), + }), + _ => js_error(WasmErr { code: ERR_SIGNING_FAILED, message: "stateful signing failed for the supplied key/leaf/message".into(), - }) + }), })?; js_value_from_serde(&stateful_signature_dto_from_signer(&signature)) } @@ -254,11 +264,15 @@ impl WasmShrincsKeypair { let message = parse_hex_bytes_with_max(message_hex, MAX_RAW_INPUT_BYTES).map_err(js_error)?; let signature = ShrincsSigner::sign_stateless_raw(self.signing_key_ref()?, &message) - .ok_or_else(|| { - js_error(WasmErr { + .map_err(|err| match err { + crate::shrincs::ShrincsSignerError::GrindBudgetExhausted => js_error(WasmErr { + code: ERR_GRIND_BUDGET_EXHAUSTED, + message: "grind counter budget exhausted for the supplied key/message".into(), + }), + _ => js_error(WasmErr { code: ERR_SIGNING_FAILED, message: "stateless signing failed for the supplied key/message".into(), - }) + }), })?; js_value_from_serde(&stateless_signature_dto_from_signer(&signature)) } @@ -592,7 +606,7 @@ pub fn shrincs_keygen( seed_material.zeroize(); // keygen only returns None for the budget range already rejected above, so this // fallback is defensive and should be unreachable in practice. - let (signing_key, public_key) = result.ok_or_else(|| { + let (signing_key, public_key) = result.map_err(|_| { js_error(WasmErr { code: ERR_KEYGEN_FAILED, message: "key generation failed for the supplied inputs".into(), @@ -644,7 +658,7 @@ pub fn shrincs_import_signing_key( }; zeroize_exported_signing_key(&mut exported); let (signing_key, public_key) = - ShrincsSigner::import_signing_key(candidate).ok_or_else(|| { + ShrincsSigner::import_signing_key(candidate).map_err(|_| { js_error(WasmErr { code: ERR_IMPORT_INVALID, message: "exported key failed validation: counter out of range \ From 1c970b47d6cccb1d222ba7b6ddd2ba5a1e909bc6 Mon Sep 17 00:00:00 2001 From: archief2910 Date: Mon, 27 Jul 2026 21:01:23 +0530 Subject: [PATCH 2/2] fix: build fixes and clippy fixes --- src/shrincs/mod.rs | 2 +- src/shrincs/signer.rs | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/shrincs/mod.rs b/src/shrincs/mod.rs index 37da65f..be87ae2 100644 --- a/src/shrincs/mod.rs +++ b/src/shrincs/mod.rs @@ -24,7 +24,7 @@ pub mod verifier; #[cfg(test)] mod vector_conformance; -pub use signer::{ShrincsSigner, ShrincsSignerResult, ShrincsSigningKey}; +pub use signer::{ShrincsSigner, ShrincsSignerError, ShrincsSignerResult, ShrincsSigningKey}; pub use verifier::{ ActionContext, ForsEntry, ForsSignature, HypertreeLayerSignature, PublicKey, RotationContext, RotationTarget, ShrincsVerifier, StatefulPublicKey, StatefulRotationTarget, StatefulSignature, diff --git a/src/shrincs/signer.rs b/src/shrincs/signer.rs index f457038..333e551 100644 --- a/src/shrincs/signer.rs +++ b/src/shrincs/signer.rs @@ -153,7 +153,8 @@ impl ShrincsSigner { public_key: &PublicKey, context: &ActionContext, ) -> ShrincsSignerResult { - let expected = word32(&public_key.public_key_commitment)?; + let expected = word32(&public_key.public_key_commitment) + .ok_or(ShrincsSignerError::InvalidConfiguration)?; let verifier = ShrincsVerifier::new(); let message = verifier.stateful_action_message_hash(expected, context); sign_stateful_raw_inner(signing_key, &message) @@ -481,8 +482,8 @@ mod tests { #[test] fn keygen_rejects_empty_or_excessive_stateful_budget() { - assert!(ShrincsSigner::keygen(b"seed", 0).is_none()); - assert!(ShrincsSigner::keygen(b"seed", MAX_STATEFUL_SIGNATURES_LIMIT + 1).is_none()); + assert!(ShrincsSigner::keygen(b"seed", 0).is_err()); + assert!(ShrincsSigner::keygen(b"seed", MAX_STATEFUL_SIGNATURES_LIMIT + 1).is_err()); } #[cfg_attr( @@ -510,7 +511,7 @@ mod tests { // The stateful signer is one-time per leaf. With a budget of one, the // next signing attempt must fail instead of reusing the previous leaf. - assert!(ShrincsSigner::sign_stateful_raw(&mut signing_key, &message).is_none()); + assert!(ShrincsSigner::sign_stateful_raw(&mut signing_key, &message).is_err()); } #[cfg_attr( @@ -679,43 +680,43 @@ mod tests { let (mut key, _) = ShrincsSigner::keygen(b"import counter seed", 4).unwrap(); key.next_stateful_leaf_index = 5; // max + 1: exhausted, still valid let (imported, _) = ShrincsSigner::import_signing_key(key).unwrap(); - assert!(ShrincsSigner::sign_stateful_raw(&mut { imported }, b"no leaves left").is_none()); + assert!(ShrincsSigner::sign_stateful_raw(&mut { imported }, b"no leaves left").is_err()); } #[test] fn import_rejects_out_of_range_counters_and_budgets() { let (mut key, _) = ShrincsSigner::keygen(b"import bounds seed", 4).unwrap(); key.next_stateful_leaf_index = 0; - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); let (mut key, _) = ShrincsSigner::keygen(b"import bounds seed", 4).unwrap(); key.next_stateful_leaf_index = 6; // max + 2 - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); let (mut key, _) = ShrincsSigner::keygen(b"import bounds seed", 4).unwrap(); key.max_stateful_signatures = 0; - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); let (mut key, _) = ShrincsSigner::keygen(b"import bounds seed", 4).unwrap(); key.max_stateful_signatures = 4097; // > MAX_STATEFUL_SIGNATURES_LIMIT - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); } #[test] fn import_rejects_tampered_roots() { let (mut key, _) = ShrincsSigner::keygen(b"import tamper seed", 4).unwrap(); key.stateful_root[0] ^= 0x01; - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); let (mut key, _) = ShrincsSigner::keygen(b"import tamper seed", 4).unwrap(); key.hypertree_root[0] ^= 0x01; - assert!(ShrincsSigner::import_signing_key(key).is_none()); + assert!(ShrincsSigner::import_signing_key(key).is_err()); // Field splice: seeds from one key, roots from another. let (key_a, _) = ShrincsSigner::keygen(b"import splice seed A", 4).unwrap(); let (mut key_b, _) = ShrincsSigner::keygen(b"import splice seed B", 4).unwrap(); key_b.stateful_root = key_a.stateful_root; - assert!(ShrincsSigner::import_signing_key(key_b).is_none()); + assert!(ShrincsSigner::import_signing_key(key_b).is_err()); } #[test]