diff --git a/crates/crypto/Cargo.toml b/crates/crypto/Cargo.toml index 77237150ae..e396ec78d2 100644 --- a/crates/crypto/Cargo.toml +++ b/crates/crypto/Cargo.toml @@ -120,6 +120,7 @@ rocksdb = { features = ["bindgen-runtime", "lz4", "zstd"], optional = t serde = { features = ["derive"], optional = true, workspace = true } sha2 = { workspace = true } sha3 = { workspace = true } +shake = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } x25519-dalek = { features = ["static_secrets"], workspace = true } @@ -143,7 +144,6 @@ itertools = { features = ["use_std"], workspace = true } miden-field = { features = ["testing"], workspace = true } proptest = { features = ["alloc"], workspace = true } seq-macro = { workspace = true } -shake = { workspace = true } tempfile = { workspace = true } [build-dependencies] diff --git a/crates/crypto/src/dsa/leansig_poseidon2/mod.rs b/crates/crypto/src/dsa/leansig_poseidon2/mod.rs new file mode 100644 index 0000000000..9916b7d6e1 --- /dev/null +++ b/crates/crypto/src/dsa/leansig_poseidon2/mod.rs @@ -0,0 +1,779 @@ +//! Miden-native LeanSig signatures using the Poseidon2 hash function. +//! +//! This is a fixed-parameter generalized XMSS construction designed to match the +//! `miden::core::crypto::dsa::leansig_poseidon2::verify` MASM procedure. It has a `2^32` epoch +//! space, 46 base-8 Winternitz chains, target sum 200, and a 32-level Merkle authentication path. +//! As in the LeanSig Ethereum instantiation, SHAKE128 is the secret-key PRF used to derive +//! one-time chain starts and deterministic encoding randomness; Poseidon is used for the public +//! message, chain, leaf, and tree hashes. This version substitutes Goldilocks Poseidon2 for the +//! reference KoalaBear Poseidon1 hash. +//! +//! The key generator commits only to the requested contiguous activation interval. Nodes outside +//! that interval are opaque, deterministically generated subtree roots. This lets applications +//! create short-lived keys without materializing all `2^32` one-time public keys. +//! +//! An epoch is the one-time nonce and leaf identifier. The secret key maintains a monotonic +//! `next_epoch` cursor: after signing an epoch, that epoch and every earlier epoch are permanently +//! rejected. Persist the updated secret key before publishing a signature so this protection also +//! survives process restarts. + +use alloc::vec::Vec; + +use miden_crypto_derive::{SilentDebug, SilentDisplay}; +use rand::{Rng, RngExt}; +use shake::{ + Shake128, + digest::{ExtendableOutput, Update, XofReader}, +}; +use thiserror::Error; + +use crate::utils::{ + ByteReader, ByteWriter, Deserializable, DeserializationError, Serializable, + zeroize::{Zeroize, ZeroizeOnDrop}, +}; +use crate::{EMPTY_WORD, Felt, Map, Word, field::PrimeField64, hash::poseidon2::Poseidon2}; + +// PARAMETERS +// ================================================================================================ + +/// Number of Winternitz chains in this LeanSig instantiation. +pub const DIMENSION: usize = 46; + +/// Number of nodes in a signature's Merkle authentication path. +pub const TREE_DEPTH: usize = 32; + +/// Number of elements in each Winternitz chain. +pub const BASE: u8 = 8; + +/// Required sum of the incomparable encoding digits. +pub const TARGET_SUM: u32 = 200; + +const HYPERCUBE_Q: u64 = 17_179_869_180; +const GOLDILOCKS_P_MINUS_ONE: u64 = 18_446_744_069_414_584_320; +const MAX_ENCODING_ATTEMPTS: u32 = 1 << 16; + +const DOMAIN_PUBLIC_KEY: u32 = 1; +const DOMAIN_MESSAGE: u32 = 2; +const DOMAIN_CHAIN: u32 = 3; +const DOMAIN_LEAF: u32 = 4; +const DOMAIN_TREE: u32 = 5; +const SECRET_KEY_VERSION: u8 = 1; + +const PRF_KEY_LENGTH: usize = 32; +const PRF_BYTES_PER_FELT: usize = 16; +const PRF_DOMAIN_SEPARATOR: [u8; 16] = [ + 0xae, 0xae, 0x22, 0xff, 0x00, 0x01, 0xfa, 0xff, 0x21, 0xaf, 0x12, 0x00, 0x01, 0x11, 0xff, 0x00, +]; +const PRF_CHAIN_START: u8 = 0; +const PRF_RHO: u8 = 1; +const PRF_INACTIVE_NODE: u8 = 2; + +// PUBLIC TYPES +// ================================================================================================ + +/// A LeanSig public key consisting of a Merkle root and public hash parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PublicKey { + root: Word, + parameter: Word, +} + +impl PublicKey { + /// Creates a public key from its constituent words. + pub const fn new(root: Word, parameter: Word) -> Self { + Self { root, parameter } + } + + /// Returns the root of the one-time-key Merkle tree. + pub const fn root(&self) -> Word { + self.root + } + + /// Returns the public hash parameter. + pub const fn parameter(&self) -> Word { + self.parameter + } + + /// Returns the commitment consumed by the MASM verifier. + pub fn to_commitment(&self) -> Word { + Poseidon2::merge_in_domain(&[self.root, self.parameter], Felt::from_u32(DOMAIN_PUBLIC_KEY)) + } + + /// Verifies a signature for `message` at `epoch`. + pub fn verify(&self, epoch: u32, message: Word, signature: &Signature) -> bool { + let Some(codeword) = encode_message(message, self.parameter, epoch, signature.rho) else { + return false; + }; + + let chain_ends = core::array::from_fn(|chain_index| { + chain( + signature.hashes[chain_index], + self.parameter, + epoch, + chain_index as u8, + codeword[chain_index], + BASE - 1, + ) + }); + let mut current = hash_leaf(&chain_ends, epoch); + let mut position = epoch; + for (level, sibling) in signature.authentication_path.iter().enumerate() { + let (left, right) = if position & 1 == 0 { + (current, *sibling) + } else { + (*sibling, current) + }; + position >>= 1; + current = hash_parent(left, right, (level + 1) as u8, position); + } + + current == self.root + } +} + +/// A Poseidon2 LeanSig signature. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Signature { + rho: Word, + hashes: [Word; DIMENSION], + authentication_path: [Word; TREE_DEPTH], +} + +impl Signature { + /// Creates a signature from its wire-format components. + pub const fn new( + rho: Word, + hashes: [Word; DIMENSION], + authentication_path: [Word; TREE_DEPTH], + ) -> Self { + Self { rho, hashes, authentication_path } + } + + /// Returns the encoding randomness. + pub const fn rho(&self) -> Word { + self.rho + } + + /// Returns the disclosed Winternitz chain nodes. + pub const fn hashes(&self) -> &[Word; DIMENSION] { + &self.hashes + } + + /// Returns the Merkle authentication path. + pub const fn authentication_path(&self) -> &[Word; TREE_DEPTH] { + &self.authentication_path + } + + /// Encodes the public key and signature in the order consumed by the MASM advice stack. + pub fn to_advice(&self, public_key: &PublicKey) -> Vec { + let mut advice = Vec::with_capacity((3 + DIMENSION + TREE_DEPTH) * Word::NUM_ELEMENTS); + advice.extend(public_key.root); + advice.extend(public_key.parameter); + advice.extend(self.rho); + advice.extend(self.hashes.iter().flat_map(|word| word.iter()).copied()); + advice.extend(self.authentication_path.iter().flat_map(|word| word.iter()).copied()); + advice + } +} + +/// A LeanSig secret key for a contiguous activation interval. +#[derive(SilentDebug, SilentDisplay)] +pub struct SecretKey { + prf_key: [u8; PRF_KEY_LENGTH], + public_key: PublicKey, + activation_epoch: u32, + num_active_epochs: u32, + next_epoch: u64, + authentication_paths: Vec<[Word; TREE_DEPTH]>, +} + +impl SecretKey { + /// Generates a key from OS-provided randomness. + #[cfg(feature = "std")] + pub fn new(activation_epoch: u32, num_active_epochs: u32) -> Result { + Self::with_rng(&mut rand::rng(), activation_epoch, num_active_epochs) + } + + /// Generates a key for a contiguous range of epochs. + /// + /// Key-generation work and secret-key storage scale linearly with `num_active_epochs`. + pub fn with_rng( + rng: &mut R, + activation_epoch: u32, + num_active_epochs: u32, + ) -> Result { + let activation_end = u64::from(activation_epoch) + u64::from(num_active_epochs); + if num_active_epochs == 0 { + return Err(KeyGenerationError::EmptyActivationInterval); + } + if activation_end > (1u64 << TREE_DEPTH) { + return Err(KeyGenerationError::ActivationIntervalOverflow { + activation_epoch, + num_active_epochs, + }); + } + + Ok(Self::from_material( + rng.random(), + random_word(rng), + activation_epoch, + num_active_epochs, + u64::from(activation_epoch), + )) + } + + fn from_material( + prf_key: [u8; PRF_KEY_LENGTH], + parameter: Word, + activation_epoch: u32, + num_active_epochs: u32, + next_epoch: u64, + ) -> Self { + let activation_end = u64::from(activation_epoch) + u64::from(num_active_epochs); + let mut builder = TreeBuilder { + prf_key, + parameter, + activation_start: u64::from(activation_epoch), + activation_end, + nodes: Map::new(), + }; + let root = builder.build_node(TREE_DEPTH as u8, 0); + + let mut authentication_paths = Vec::with_capacity(num_active_epochs as usize); + for offset in 0..num_active_epochs { + let epoch = activation_epoch.wrapping_add(offset); + authentication_paths.push(builder.authentication_path(epoch)); + } + + Self { + prf_key, + public_key: PublicKey::new(root, parameter), + activation_epoch, + num_active_epochs, + next_epoch, + authentication_paths, + } + } + + /// Returns the public key corresponding to this secret key. + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Returns the first active epoch. + pub const fn activation_epoch(&self) -> u32 { + self.activation_epoch + } + + /// Returns the number of active epochs. + pub const fn num_active_epochs(&self) -> u32 { + self.num_active_epochs + } + + /// Returns the next unused epoch, or `None` if the activation interval is exhausted. + pub fn next_epoch(&self) -> Option { + (self.next_epoch < self.activation_end()).then_some(self.next_epoch as u32) + } + + /// Signs `message` at the next unused epoch and returns that epoch with the signature. + pub fn sign_next(&mut self, message: Word) -> Result<(u32, Signature), SigningError> { + let epoch = self.next_epoch().ok_or(SigningError::KeyExhausted)?; + self.sign(epoch, message).map(|signature| (epoch, signature)) + } + + /// Signs `message` using the one-time key identified by `epoch`. + /// + /// On success, this advances the key's nonce cursor to `epoch + 1`. Any skipped epochs become + /// unusable, which ensures the signer can never move its one-time nonce backwards. + pub fn sign(&mut self, epoch: u32, message: Word) -> Result { + let path_index = u64::from(epoch).checked_sub(u64::from(self.activation_epoch)); + let Some(path_index) = + path_index.filter(|index| *index < u64::from(self.num_active_epochs)) + else { + return Err(SigningError::EpochNotActive { + epoch, + activation_epoch: self.activation_epoch, + num_active_epochs: self.num_active_epochs, + }); + }; + if u64::from(epoch) < self.next_epoch { + return Err(SigningError::EpochAlreadyUsed { epoch, next_epoch: self.next_epoch }); + } + + let (rho, codeword) = (0..MAX_ENCODING_ATTEMPTS) + .find_map(|attempt| { + let rho = derive_rho(&self.prf_key, message, epoch, attempt); + encode_message(message, self.public_key.parameter, epoch, rho) + .map(|codeword| (rho, codeword)) + }) + .ok_or(SigningError::EncodingAttemptsExceeded { attempts: MAX_ENCODING_ATTEMPTS })?; + + let hashes = core::array::from_fn(|chain_index| { + let start = derive_chain_start(&self.prf_key, epoch, chain_index as u8); + chain( + start, + self.public_key.parameter, + epoch, + chain_index as u8, + 0, + codeword[chain_index], + ) + }); + + let signature = Signature::new(rho, hashes, self.authentication_paths[path_index as usize]); + self.next_epoch = u64::from(epoch) + 1; + Ok(signature) + } + + fn activation_end(&self) -> u64 { + u64::from(self.activation_epoch) + u64::from(self.num_active_epochs) + } +} + +impl Drop for SecretKey { + fn drop(&mut self) { + self.prf_key.zeroize(); + } +} + +impl ZeroizeOnDrop for SecretKey {} + +// SERIALIZATION +// ================================================================================================ + +impl Serializable for PublicKey { + fn write_into(&self, target: &mut W) { + target.write(self.root); + target.write(self.parameter); + } +} + +impl Deserializable for PublicKey { + fn read_from(source: &mut R) -> Result { + Ok(Self::new(source.read()?, source.read()?)) + } +} + +impl Serializable for Signature { + fn write_into(&self, target: &mut W) { + target.write(self.rho); + target.write_many(self.hashes); + target.write_many(self.authentication_path); + } +} + +impl Deserializable for Signature { + fn read_from(source: &mut R) -> Result { + Ok(Self::new(source.read()?, read_word_array(source)?, read_word_array(source)?)) + } +} + +impl Serializable for SecretKey { + /// Serializes the PRF key, parameter, activation interval, and monotonic nonce cursor. + /// + /// Authentication paths and the public root are regenerated during deserialization. Crucially, + /// `next_epoch` is persisted, so a loaded key continues to reject consumed epochs. + fn write_into(&self, target: &mut W) { + target.write_u8(SECRET_KEY_VERSION); + target.write_bytes(&self.prf_key); + target.write(self.public_key.parameter); + target.write_u32(self.activation_epoch); + target.write_u32(self.num_active_epochs); + target.write_u64(self.next_epoch); + } +} + +impl Deserializable for SecretKey { + fn read_from(source: &mut R) -> Result { + let version = source.read_u8()?; + if version != SECRET_KEY_VERSION { + return Err(DeserializationError::InvalidValue(alloc::format!( + "unsupported LeanSig secret-key version {version}" + ))); + } + + let prf_key = source.read_array()?; + let parameter = source.read()?; + let activation_epoch = source.read_u32()?; + let num_active_epochs = source.read_u32()?; + let next_epoch = source.read_u64()?; + let activation_end = u64::from(activation_epoch) + u64::from(num_active_epochs); + + if num_active_epochs == 0 || activation_end > (1u64 << TREE_DEPTH) { + return Err(DeserializationError::InvalidValue( + "invalid LeanSig activation interval".into(), + )); + } + if !(u64::from(activation_epoch)..=activation_end).contains(&next_epoch) { + return Err(DeserializationError::InvalidValue( + "invalid LeanSig next-epoch cursor".into(), + )); + } + + Ok(Self::from_material( + prf_key, + parameter, + activation_epoch, + num_active_epochs, + next_epoch, + )) + } +} + +fn read_word_array( + source: &mut R, +) -> Result<[Word; N], DeserializationError> { + let mut words = [EMPTY_WORD; N]; + for word in &mut words { + *word = source.read()?; + } + Ok(words) +} + +/// Errors returned while generating a LeanSig key. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum KeyGenerationError { + /// The requested activation interval contains no epochs. + #[error("LeanSig activation interval must contain at least one epoch")] + EmptyActivationInterval, + + /// The requested activation interval extends past the `u32` epoch space. + #[error( + "LeanSig activation interval ({activation_epoch}, {num_active_epochs}) exceeds the u32 epoch space" + )] + ActivationIntervalOverflow { + /// First requested epoch. + activation_epoch: u32, + /// Number of requested epochs. + num_active_epochs: u32, + }, +} + +/// Errors returned while signing with a LeanSig key. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum SigningError { + /// The requested epoch is outside the key's activation interval. + #[error( + "LeanSig epoch {epoch} is outside activation interval starting at {activation_epoch} with length {num_active_epochs}" + )] + EpochNotActive { + /// Requested epoch. + epoch: u32, + /// First active epoch. + activation_epoch: u32, + /// Number of active epochs. + num_active_epochs: u32, + }, + + /// The requested epoch has already been consumed or skipped. + #[error("LeanSig epoch {epoch} is older than the next unused epoch {next_epoch}")] + EpochAlreadyUsed { + /// Requested epoch. + epoch: u32, + /// Monotonic nonce cursor after the most recent signature. + next_epoch: u64, + }, + + /// Every epoch in the activation interval has been consumed or skipped. + #[error("LeanSig secret key has no unused active epochs")] + KeyExhausted, + + /// Rejection sampling did not produce an incomparable encoding. + #[error("LeanSig encoding failed after {attempts} attempts")] + EncodingAttemptsExceeded { + /// Number of attempted encodings. + attempts: u32, + }, +} + +// TREE CONSTRUCTION +// ================================================================================================ + +struct TreeBuilder { + prf_key: [u8; PRF_KEY_LENGTH], + parameter: Word, + activation_start: u64, + activation_end: u64, + nodes: Map<(u8, u32), Word>, +} + +impl TreeBuilder { + fn build_node(&mut self, level: u8, position: u32) -> Word { + if let Some(node) = self.nodes.get(&(level, position)) { + return *node; + } + + let subtree_start = u64::from(position) << level; + let subtree_end = (u64::from(position) + 1) << level; + if subtree_end <= self.activation_start || subtree_start >= self.activation_end { + return inactive_node(&self.prf_key, level, position); + } + + let node = if level == 0 { + self.one_time_public_key(position) + } else { + let child_level = level - 1; + let left = self.build_node(child_level, position * 2); + let right = self.build_node(child_level, position * 2 + 1); + hash_parent(left, right, level, position) + }; + self.nodes.insert((level, position), node); + node + } + + fn one_time_public_key(&self, epoch: u32) -> Word { + let chain_ends = core::array::from_fn(|chain_index| { + let start = derive_chain_start(&self.prf_key, epoch, chain_index as u8); + chain(start, self.parameter, epoch, chain_index as u8, 0, BASE - 1) + }); + hash_leaf(&chain_ends, epoch) + } + + fn authentication_path(&self, epoch: u32) -> [Word; TREE_DEPTH] { + core::array::from_fn(|level| { + let level = level as u8; + let sibling_position = (epoch >> level) ^ 1; + self.nodes + .get(&(level, sibling_position)) + .copied() + .unwrap_or_else(|| inactive_node(&self.prf_key, level, sibling_position)) + }) + } +} + +impl Drop for TreeBuilder { + fn drop(&mut self) { + self.prf_key.zeroize(); + } +} + +// HASH AND ENCODING HELPERS +// ================================================================================================ + +fn encode_message( + message: Word, + parameter: Word, + epoch: u32, + rho: Word, +) -> Option<[u8; DIMENSION]> { + let state = message_hash_state(message, parameter, epoch, rho); + let mut codeword = [0u8; DIMENSION]; + let mut cursor = 0; + + for felt in state.iter().take(5) { + let value = felt.as_canonical_u64(); + if value == GOLDILOCKS_P_MINUS_ONE { + return None; + } + + let mut quotient = value / HYPERCUBE_Q; + for _ in 0..10 { + if cursor == DIMENSION { + break; + } + codeword[cursor] = (quotient % u64::from(BASE)) as u8; + quotient /= u64::from(BASE); + cursor += 1; + } + } + + (codeword.iter().map(|&digit| u32::from(digit)).sum::() == TARGET_SUM).then_some(codeword) +} + +fn message_hash_state(message: Word, parameter: Word, epoch: u32, rho: Word) -> [Felt; 12] { + let mut state = [Felt::ZERO; Poseidon2::STATE_WIDTH]; + state[..4].copy_from_slice(message.as_elements()); + state[4..8].copy_from_slice(parameter.as_elements()); + state[8..].copy_from_slice(&capacity(DOMAIN_MESSAGE, epoch, 0, 0)); + Poseidon2::apply_permutation(&mut state); + + state[..4].copy_from_slice(rho.as_elements()); + state[4..8].fill(Felt::ZERO); + Poseidon2::apply_permutation(&mut state); + state +} + +fn derive_chain_start(prf_key: &[u8; PRF_KEY_LENGTH], epoch: u32, chain_index: u8) -> Word { + let mut hasher = prf_hasher(PRF_CHAIN_START, prf_key); + hasher.update(&epoch.to_be_bytes()); + hasher.update(&u64::from(chain_index).to_be_bytes()); + shake_word(hasher) +} + +fn derive_rho(prf_key: &[u8; PRF_KEY_LENGTH], message: Word, epoch: u32, attempt: u32) -> Word { + let mut hasher = prf_hasher(PRF_RHO, prf_key); + hasher.update(&epoch.to_be_bytes()); + hasher.update(&message.to_bytes()); + hasher.update(&u64::from(attempt).to_be_bytes()); + shake_word(hasher) +} + +fn chain( + mut current: Word, + parameter: Word, + epoch: u32, + chain_index: u8, + start_position: u8, + end_position: u8, +) -> Word { + for position in start_position + 1..=end_position { + current = permute_rate( + current, + parameter, + capacity(DOMAIN_CHAIN, epoch, u32::from(chain_index), u32::from(position)), + ); + } + current +} + +fn hash_leaf(chain_ends: &[Word; DIMENSION], epoch: u32) -> Word { + let mut state = [Felt::ZERO; Poseidon2::STATE_WIDTH]; + state[8..].copy_from_slice(&capacity(DOMAIN_LEAF, epoch, DIMENSION as u32, 0)); + for pair in chain_ends.chunks_exact(2) { + state[..4].copy_from_slice(pair[0].as_elements()); + state[4..8].copy_from_slice(pair[1].as_elements()); + Poseidon2::apply_permutation(&mut state); + } + Word::new(state[..4].try_into().expect("digest has four elements")) +} + +fn hash_parent(left: Word, right: Word, level: u8, position: u32) -> Word { + permute_rate(left, right, capacity(DOMAIN_TREE, u32::from(level), position, 0)) +} + +fn inactive_node(prf_key: &[u8; PRF_KEY_LENGTH], level: u8, position: u32) -> Word { + let mut hasher = prf_hasher(PRF_INACTIVE_NODE, prf_key); + hasher.update(&[level]); + hasher.update(&position.to_be_bytes()); + shake_word(hasher) +} + +fn prf_hasher(domain: u8, prf_key: &[u8; PRF_KEY_LENGTH]) -> Shake128 { + let mut hasher = Shake128::default(); + hasher.update(&PRF_DOMAIN_SEPARATOR); + hasher.update(&[domain]); + hasher.update(prf_key); + hasher +} + +fn shake_word(hasher: Shake128) -> Word { + let mut reader = hasher.finalize_xof(); + Word::new(core::array::from_fn(|_| { + let mut bytes = [0u8; PRF_BYTES_PER_FELT]; + reader.read(&mut bytes); + let value = u128::from_be_bytes(bytes) % u128::from(Felt::ORDER_U64); + Felt::new_unchecked(value as u64) + })) +} + +fn permute_rate(left: Word, right: Word, capacity: [Felt; 4]) -> Word { + let mut state = [Felt::ZERO; Poseidon2::STATE_WIDTH]; + state[..4].copy_from_slice(left.as_elements()); + state[4..8].copy_from_slice(right.as_elements()); + state[8..].copy_from_slice(&capacity); + Poseidon2::apply_permutation(&mut state); + Word::new(state[..4].try_into().expect("digest has four elements")) +} + +fn capacity(domain: u32, a: u32, b: u32, c: u32) -> [Felt; 4] { + [domain, a, b, c].map(Felt::from_u32) +} + +fn random_word(rng: &mut impl Rng) -> Word { + Word::new(core::array::from_fn(|_| { + loop { + if let Ok(felt) = Felt::new(rng.random::()) { + break felt; + } + } + })) +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + use super::*; + + #[test] + fn sign_and_verify_across_activation_interval() { + let mut rng = ChaCha20Rng::from_seed([0x51; 32]); + let mut secret_key = SecretKey::with_rng(&mut rng, 0x1020_3040, 3).unwrap(); + let public_key = secret_key.public_key(); + + for epoch in 0x1020_3040..0x1020_3043 { + let message = random_word(&mut rng); + let signature = secret_key.sign(epoch, message).unwrap(); + assert!(public_key.verify(epoch, message, &signature)); + assert!(!public_key.verify(epoch ^ 1, message, &signature)); + } + } + + #[test] + fn signing_is_deterministic() { + let mut left_rng = ChaCha20Rng::from_seed([0x52; 32]); + let mut right_rng = ChaCha20Rng::from_seed([0x52; 32]); + let mut left_key = SecretKey::with_rng(&mut left_rng, 7, 1).unwrap(); + let mut right_key = SecretKey::with_rng(&mut right_rng, 7, 1).unwrap(); + let message = random_word(&mut left_rng); + + assert_eq!(left_key.sign(7, message), right_key.sign(7, message)); + } + + #[test] + fn rejects_inactive_epoch() { + let mut rng = ChaCha20Rng::from_seed([0x53; 32]); + let mut secret_key = SecretKey::with_rng(&mut rng, 7, 1).unwrap(); + + assert!(matches!( + secret_key.sign(8, EMPTY_WORD), + Err(SigningError::EpochNotActive { .. }) + )); + } + + #[test] + fn rejects_reused_and_skipped_epochs() { + let mut rng = ChaCha20Rng::from_seed([0x54; 32]); + let mut secret_key = SecretKey::with_rng(&mut rng, 7, 4).unwrap(); + + secret_key.sign(9, EMPTY_WORD).unwrap(); + assert_eq!(secret_key.next_epoch(), Some(10)); + assert!(matches!( + secret_key.sign(9, EMPTY_WORD), + Err(SigningError::EpochAlreadyUsed { .. }) + )); + assert!(matches!( + secret_key.sign(8, EMPTY_WORD), + Err(SigningError::EpochAlreadyUsed { .. }) + )); + + let (epoch, _) = secret_key.sign_next(EMPTY_WORD).unwrap(); + assert_eq!(epoch, 10); + assert_eq!(secret_key.next_epoch(), None); + } + + #[test] + fn serialization_persists_nonce_cursor() { + let mut rng = ChaCha20Rng::from_seed([0x55; 32]); + let mut secret_key = SecretKey::with_rng(&mut rng, 7, 3).unwrap(); + let public_key = secret_key.public_key(); + let first_signature = secret_key.sign(7, EMPTY_WORD).unwrap(); + assert!(public_key.verify(7, EMPTY_WORD, &first_signature)); + + let encoded = secret_key.to_bytes(); + let mut restored = SecretKey::read_from_bytes(&encoded).unwrap(); + assert_eq!(restored.public_key(), public_key); + assert_eq!(restored.next_epoch(), Some(8)); + assert!(matches!( + restored.sign(7, EMPTY_WORD), + Err(SigningError::EpochAlreadyUsed { .. }) + )); + + let second_signature = restored.sign(8, EMPTY_WORD).unwrap(); + assert!(public_key.verify(8, EMPTY_WORD, &second_signature)); + } +} diff --git a/crates/crypto/src/dsa/mod.rs b/crates/crypto/src/dsa/mod.rs index 6288b3b416..fc3c12f9a6 100644 --- a/crates/crypto/src/dsa/mod.rs +++ b/crates/crypto/src/dsa/mod.rs @@ -3,3 +3,4 @@ pub mod ecdsa_k256_keccak; pub mod eddsa_25519_sha512; pub mod falcon512_poseidon2; +pub mod leansig_poseidon2; diff --git a/crates/lib/core/README.md b/crates/lib/core/README.md index bc2d75e900..be22e2bc9c 100644 --- a/crates/lib/core/README.md +++ b/crates/lib/core/README.md @@ -27,6 +27,7 @@ Currently, Miden core library contains just a few modules, which are listed belo - [miden::core::collections::sorted_array](./docs/collections/sorted_array.md) - [miden::core::crypto::dsa::ecdsa_k256_keccak](./docs/crypto/dsa/ecdsa_k256_keccak.md) - [miden::core::crypto::dsa::falcon512_poseidon2](./docs/crypto/dsa/falcon512_poseidon2.md) +- [miden::core::crypto::dsa::leansig_poseidon2](./docs/crypto/dsa/leansig_poseidon2.md) - [miden::core::crypto::hashes::poseidon2](./docs/crypto/hashes/poseidon2.md) - [miden::core::crypto::hashes::blake3](./docs/crypto/hashes/blake3.md) - [miden::core::crypto::hashes::keccak256](./docs/crypto/hashes/keccak256.md) diff --git a/crates/lib/core/asm/crypto/dsa/leansig_poseidon2.masm b/crates/lib/core/asm/crypto/dsa/leansig_poseidon2.masm new file mode 100644 index 0000000000..0c27145b01 --- /dev/null +++ b/crates/lib/core/asm/crypto/dsa/leansig_poseidon2.masm @@ -0,0 +1,308 @@ +use miden::core::crypto::hashes::poseidon2 +use miden::core::math::u64 + +# MIDEN-NATIVE LEANSIG PARAMETERS +# ================================================================================================ + +const DIMENSION = 46 +const BASE = 8 +const TARGET_SUM = 200 +const TREE_DEPTH = 32 + +# Goldilocks p = Q * 8^10 + 1. Rejecting p - 1 before division makes every accepted quotient +# uniform in [0, 8^10), from which ten base-8 digits are extracted. +const HYPERCUBE_Q_LO = 4294967292 +const HYPERCUBE_Q_HI = 3 +const GOLDILOCKS_P_MINUS_ONE = 18446744069414584320 + +const DOMAIN_PK = 1 +const DOMAIN_MESSAGE = 2 +const DOMAIN_CHAIN = 3 +const DOMAIN_LEAF = 4 +const DOMAIN_TREE = 5 + +# VERIFY LOCAL MEMORY ADDRESSES +# ================================================================================================ + +const VERIFY_ROOT_ADDR = 0 +const VERIFY_PARAMETER_ADDR = 4 +const VERIFY_MESSAGE_ADDR = 8 +const VERIFY_EPOCH_ADDR = 12 +const VERIFY_CHAIN_INDEX_ADDR = 13 +const VERIFY_MERKLE_POSITION_ADDR = 13 +const VERIFY_CODEWORD_READ_PTR_ADDR = 14 +const VERIFY_MERKLE_LEVEL_ADDR = 14 +const VERIFY_ENDPOINT_WRITE_PTR_ADDR = 15 +const VERIFY_RHO_ADDR = 16 +const VERIFY_CURRENT_NODE_ADDR = 16 +const VERIFY_MESSAGE_HASH_RATE_0_ADDR = 24 +const VERIFY_MESSAGE_HASH_RATE_1_ADDR = 28 +const VERIFY_MESSAGE_HASH_ELEMENT_0_ADDR = 24 +const VERIFY_MESSAGE_HASH_ELEMENT_1_ADDR = 25 +const VERIFY_MESSAGE_HASH_ELEMENT_2_ADDR = 26 +const VERIFY_MESSAGE_HASH_ELEMENT_3_ADDR = 27 +const VERIFY_MESSAGE_HASH_ELEMENT_4_ADDR = 28 +const VERIFY_CODEWORD_ADDR = 32 +const VERIFY_ENDPOINTS_ADDR = 80 + +# COMPUTE_CHAIN_END LOCAL MEMORY ADDRESSES +# ================================================================================================ + +const CHAIN_PARAMETER_ADDR = 0 +const CHAIN_EPOCH_ADDR = 4 +const CHAIN_INDEX_ADDR = 5 +const CHAIN_POSITION_ADDR = 6 +const CHAIN_CURRENT_ADDR = 8 + +# HASH_PARENT LOCAL MEMORY ADDRESSES +# ================================================================================================ + +const PARENT_POSITION_ADDR = 0 +const PARENT_LEVEL_ADDR = 1 +const PARENT_IS_RIGHT_CHILD_ADDR = 2 +const PARENT_SIBLING_ADDR = 4 +const PARENT_CURRENT_ADDR = 8 + +# VERIFIER +# ================================================================================================ + +#! Verifies a Miden-native Poseidon2 LeanSig signature. +#! +#! This is a fixed-parameter generalized-XMSS verifier with lifetime 2^32, target-sum dimension 46, +#! base 8, target sum 200, and a 32-level authentication path. It follows the verification flow of +#! `leanEthereum/leanSig`, but uses Miden's native Goldilocks Poseidon2 permutation and is therefore +#! not wire-compatible with the reference KoalaBear Poseidon1 instantiation. +#! +#! Inputs: +#! Operand stack: [PK_COMM, MSG, EPOCH, ...] +#! Advice stack: [ROOT: word | PARAMETER: word | RHO: word | +#! SIG_HASHES: [word; 46] | AUTH_PATH: [word; 32] | ...] +#! Outputs: +#! Operand stack: [...] +#! Advice stack: [...] +#! +#! `PK_COMM`, `MSG`, `ROOT`, `PARAMETER`, `RHO`, every signature hash, and every authentication +#! node are words. `EPOCH` must be a canonical u32. `PK_COMM` is Poseidon2::merge_in_domain(1, +#! ROOT, PARAMETER). The procedure traps on malformed inputs or failed verification. +#! +#! Local memory layout (element addresses): +#! loc[0 .. 4] : public-key root +#! loc[4 .. 8] : public hash parameter +#! loc[8 .. 12] : message +#! loc[12] : epoch +#! loc[13] : chain index, later reused for the current Merkle position +#! loc[14] : codeword pointer, later reused for the current Merkle level +#! loc[15] : endpoint write pointer +#! loc[16.. 20] : rho, later reused for the current Merkle node +#! loc[24.. 32] : message-hash rate output +#! loc[32.. 78] : target-sum codeword +#! loc[80..264] : 46 reconstructed Winternitz chain endpoints +@locals(264) +pub proc verify(pk_comm: word, msg: word, epoch: u32) + # Load and bind ROOT and PARAMETER before trusting any remaining advice. + adv_pushw + adv_pushw + loc_storew_le.VERIFY_PARAMETER_ADDR + swapw + loc_storew_le.VERIFY_ROOT_ADDR + push.DOMAIN_PK + exec.poseidon2::merge_in_domain + assert_eqw.err="invalid LeanSig public key commitment" + # => [MSG, EPOCH, ...] + + loc_storew_le.VERIFY_MESSAGE_ADDR dropw + u32assert.err="LeanSig epoch must be a u32" + loc_store.VERIFY_EPOCH_ADDR + # => [...] + + adv_pushw + loc_storew_le.VERIFY_RHO_ADDR dropw + + # Message hash, block 1: absorb (MSG, PARAMETER) with C = [DOMAIN_MESSAGE, EPOCH, 0, 0]. + push.0 push.0 loc_load.VERIFY_EPOCH_ADDR push.DOMAIN_MESSAGE + padw loc_loadw_le.VERIFY_PARAMETER_ADDR + padw loc_loadw_le.VERIFY_MESSAGE_ADDR + hperm + + # Message hash, block 2: replace the rate with (RHO, 0) and permute again. + dropw dropw + padw + padw loc_loadw_le.VERIFY_RHO_ADDR + hperm + + # Save the first eight rate elements; the first five are decoded below. + loc_storew_le.VERIFY_MESSAGE_HASH_RATE_0_ADDR dropw + loc_storew_le.VERIFY_MESSAGE_HASH_RATE_1_ADDR dropw + dropw + + # Decode 46 base-8 chunks and enforce the incomparable target-sum code. + push.0 + locaddr.VERIFY_CODEWORD_ADDR + loc_load.VERIFY_MESSAGE_HASH_ELEMENT_0_ADDR exec.decode_10_chunks + loc_load.VERIFY_MESSAGE_HASH_ELEMENT_1_ADDR exec.decode_10_chunks + loc_load.VERIFY_MESSAGE_HASH_ELEMENT_2_ADDR exec.decode_10_chunks + loc_load.VERIFY_MESSAGE_HASH_ELEMENT_3_ADDR exec.decode_10_chunks + loc_load.VERIFY_MESSAGE_HASH_ELEMENT_4_ADDR exec.decode_6_chunks + drop + push.TARGET_SUM + eq assert.err="invalid LeanSig target-sum encoding" + + # Reconstruct all Winternitz chain endpoints. + push.0 loc_store.VERIFY_CHAIN_INDEX_ADDR + locaddr.VERIFY_CODEWORD_ADDR loc_store.VERIFY_CODEWORD_READ_PTR_ADDR + locaddr.VERIFY_ENDPOINTS_ADDR loc_store.VERIFY_ENDPOINT_WRITE_PTR_ADDR + repeat.DIMENSION + padw loc_loadw_le.VERIFY_PARAMETER_ADDR + loc_load.VERIFY_EPOCH_ADDR + loc_load.VERIFY_CHAIN_INDEX_ADDR + loc_load.VERIFY_CODEWORD_READ_PTR_ADDR mem_load + adv_pushw + exec.compute_chain_end + + loc_load.VERIFY_ENDPOINT_WRITE_PTR_ADDR mem_storew_le dropw + loc_load.VERIFY_CHAIN_INDEX_ADDR add.1 loc_store.VERIFY_CHAIN_INDEX_ADDR + loc_load.VERIFY_CODEWORD_READ_PTR_ADDR add.1 loc_store.VERIFY_CODEWORD_READ_PTR_ADDR + loc_load.VERIFY_ENDPOINT_WRITE_PTR_ADDR add.4 loc_store.VERIFY_ENDPOINT_WRITE_PTR_ADDR + end + + # Hash the 46 endpoint words into the epoch leaf. DIMENSION is even, so no rate padding is + # required. C = [DOMAIN_LEAF, EPOCH, DIMENSION, 0]. + locaddr.VERIFY_ENDPOINTS_ADDR dup add.184 swap + push.0 push.DIMENSION loc_load.VERIFY_EPOCH_ADDR push.DOMAIN_LEAF + padw padw + exec.poseidon2::absorb_double_words_from_memory + exec.poseidon2::squeeze_digest + movup.4 drop movup.4 drop + loc_storew_le.VERIFY_CURRENT_NODE_ADDR dropw + + # Reconstruct the fixed-depth Merkle root. + loc_load.VERIFY_EPOCH_ADDR loc_store.VERIFY_MERKLE_POSITION_ADDR + push.1 loc_store.VERIFY_MERKLE_LEVEL_ADDR + repeat.TREE_DEPTH + loc_load.VERIFY_MERKLE_LEVEL_ADDR + loc_load.VERIFY_MERKLE_POSITION_ADDR + padw loc_loadw_le.VERIFY_CURRENT_NODE_ADDR + adv_pushw + exec.hash_parent + loc_storew_le.VERIFY_CURRENT_NODE_ADDR dropw + + loc_load.VERIFY_MERKLE_POSITION_ADDR u32div.2 + loc_store.VERIFY_MERKLE_POSITION_ADDR + loc_load.VERIFY_MERKLE_LEVEL_ADDR add.1 loc_store.VERIFY_MERKLE_LEVEL_ADDR + end + + padw loc_loadw_le.VERIFY_ROOT_ADDR + padw loc_loadw_le.VERIFY_CURRENT_NODE_ADDR + assert_eqw.err="LeanSig Merkle authentication failed" +end + +# INTERNAL HELPERS +# ================================================================================================ + +#! Rejection-samples one message-hash field element, divides it by Q, and writes ten base-8 digits. +#! +#! Input: [value, write_ptr, sum, ...] +#! Output: [write_ptr + 10, sum + digit_sum, ...] +proc decode_10_chunks(value: felt, write_ptr: u32, sum: u32) -> (new_write_ptr: u32, new_sum: u32) + dup push.GOLDILOCKS_P_MINUS_ONE + eq assertz.err="LeanSig message hash rejected" + + u32split + push.HYPERCUBE_Q_HI push.HYPERCUBE_Q_LO + exec.u64::div + swap assertz + + repeat.10 + u32divmod.BASE + dup dup.3 mem_store + movup.3 add movdn.2 + swap add.1 swap + end + assertz +end + +#! As `decode_10_chunks`, but writes only the first six digits of the fifth field element. +#! +#! Input: [value, write_ptr, sum, ...] +#! Output: [write_ptr + 6, sum + digit_sum, ...] +proc decode_6_chunks(value: felt, write_ptr: u32, sum: u32) -> (new_write_ptr: u32, new_sum: u32) + dup push.GOLDILOCKS_P_MINUS_ONE + eq assertz.err="LeanSig message hash rejected" + + u32split + push.HYPERCUBE_Q_HI push.HYPERCUBE_Q_LO + exec.u64::div + swap assertz + + repeat.6 + u32divmod.BASE + dup dup.3 mem_store + movup.3 add movdn.2 + swap add.1 swap + end + drop +end + +#! Walks one signature hash from codeword position xi to the end of its base-8 chain. +#! +#! Input: [CURRENT, xi, chain_index, EPOCH, PARAMETER, ...] +#! Output: [CHAIN_END, ...] +@locals(12) +proc compute_chain_end(current: word, position: u8, chain_index: u8, epoch: u32, parameter: word) -> word + loc_storew_le.CHAIN_CURRENT_ADDR dropw + loc_store.CHAIN_POSITION_ADDR + loc_store.CHAIN_INDEX_ADDR + loc_store.CHAIN_EPOCH_ADDR + loc_storew_le.CHAIN_PARAMETER_ADDR dropw + + loc_load.CHAIN_POSITION_ADDR lt.7 + while.true + loc_load.CHAIN_POSITION_ADDR add.1 loc_store.CHAIN_POSITION_ADDR + + loc_load.CHAIN_POSITION_ADDR + loc_load.CHAIN_INDEX_ADDR + loc_load.CHAIN_EPOCH_ADDR + push.DOMAIN_CHAIN + padw loc_loadw_le.CHAIN_PARAMETER_ADDR + padw loc_loadw_le.CHAIN_CURRENT_ADDR + hperm + exec.poseidon2::squeeze_digest + loc_storew_le.CHAIN_CURRENT_ADDR dropw + + loc_load.CHAIN_POSITION_ADDR lt.7 + end + + padw loc_loadw_le.CHAIN_CURRENT_ADDR +end + +#! Hashes a current Merkle node with its sibling using the position-dependent tree tweak. +#! +#! Input: [SIBLING, CURRENT, position, level, ...] +#! Output: [PARENT, ...] +@locals(12) +proc hash_parent(sibling: word, current: word, position: u32, level: u8) -> word + movup.9 loc_store.PARENT_LEVEL_ADDR + movup.8 loc_store.PARENT_POSITION_ADDR + loc_storew_le.PARENT_SIBLING_ADDR dropw + loc_storew_le.PARENT_CURRENT_ADDR dropw + + loc_load.PARENT_POSITION_ADDR is_odd loc_store.PARENT_IS_RIGHT_CHILD_ADDR + loc_load.PARENT_POSITION_ADDR u32div.2 loc_store.PARENT_POSITION_ADDR + push.0 + loc_load.PARENT_POSITION_ADDR + loc_load.PARENT_LEVEL_ADDR + push.DOMAIN_TREE + + # Odd positions are right children, so SIBLING is the left rate word. + loc_load.PARENT_IS_RIGHT_CHILD_ADDR + if.true + padw loc_loadw_le.PARENT_CURRENT_ADDR + padw loc_loadw_le.PARENT_SIBLING_ADDR + else + padw loc_loadw_le.PARENT_SIBLING_ADDR + padw loc_loadw_le.PARENT_CURRENT_ADDR + end + + hperm + exec.poseidon2::squeeze_digest +end diff --git a/crates/lib/core/asm/crypto/dsa/mod.masm b/crates/lib/core/asm/crypto/dsa/mod.masm index 0aac5b7e6a..625e33f6fe 100644 --- a/crates/lib/core/asm/crypto/dsa/mod.masm +++ b/crates/lib/core/asm/crypto/dsa/mod.masm @@ -1,2 +1,3 @@ pub mod ecdsa_k256_keccak pub mod falcon512_poseidon2 +pub mod leansig_poseidon2 diff --git a/crates/lib/core/docs/crypto/dsa/leansig_poseidon2.md b/crates/lib/core/docs/crypto/dsa/leansig_poseidon2.md new file mode 100644 index 0000000000..b7b7f89d09 --- /dev/null +++ b/crates/lib/core/docs/crypto/dsa/leansig_poseidon2.md @@ -0,0 +1,5 @@ + +## miden::core::crypto::dsa::leansig_poseidon2 +| Procedure | Description | +| ----------- | ------------- | +| verify | Verifies a Miden-native Poseidon2 LeanSig signature.

This is a fixed-parameter generalized-XMSS verifier with lifetime 2^32, target-sum dimension 46,
base 8, target sum 200, and a 32-level authentication path. It follows the verification flow of
`leanEthereum/leanSig`, but uses Miden's native Goldilocks Poseidon2 permutation and is therefore
not wire-compatible with the reference KoalaBear Poseidon1 instantiation.

Inputs:
Operand stack: [PK_COMM, MSG, EPOCH, ...]
Advice stack: [ROOT: word \| PARAMETER: word \| RHO: word \|
SIG_HASHES: [word; 46] \| AUTH_PATH: [word; 32] \| ...]
Outputs:
Operand stack: [...]
Advice stack: [...]

`PK_COMM`, `MSG`, `ROOT`, `PARAMETER`, `RHO`, every signature hash, and every authentication
node are words. `EPOCH` must be a canonical u32. `PK_COMM` is Poseidon2::merge_in_domain(1,
ROOT, PARAMETER). The procedure traps on malformed inputs or failed verification.

Local memory layout (element addresses):
loc[0 .. 4] : public-key root
loc[4 .. 8] : public hash parameter
loc[8 .. 12] : message
loc[12] : epoch
loc[13] : chain index, later reused for the current Merkle position
loc[14] : codeword pointer, later reused for the current Merkle level
loc[15] : endpoint write pointer
loc[16.. 20] : rho, later reused for the current Merkle node
loc[24.. 32] : message-hash rate output
loc[32.. 78] : target-sum codeword
loc[80..264] : 46 reconstructed Winternitz chain endpoints
| diff --git a/crates/lib/core/src/dsa.rs b/crates/lib/core/src/dsa.rs index 2a78aa6466..32ba9ec3cc 100644 --- a/crates/lib/core/src/dsa.rs +++ b/crates/lib/core/src/dsa.rs @@ -9,6 +9,7 @@ //! Each submodule corresponds to a specific signature scheme: //! - [`ecdsa_k256_keccak`]: ECDSA over secp256k1 with Keccak256 hashing //! - [`falcon512_poseidon2`]: Falcon-512 with Poseidon2 hashing +//! - [`leansig_poseidon2`]: Miden-native LeanSig with Poseidon2 hashing // ECDSA K256 KECCAK // ================================================================================================ @@ -178,3 +179,50 @@ pub mod falcon512_poseidon2 { result } } + +// LEANSIG POSEIDON2 +// ================================================================================================ + +/// Rust signer and encoding helpers for the Miden-native Poseidon2 LeanSig verifier. +pub mod leansig_poseidon2 { + extern crate alloc; + + use alloc::vec::Vec; + + use miden_core::{Felt, Word}; + + pub use miden_crypto::dsa::leansig_poseidon2::{ + BASE, DIMENSION, KeyGenerationError, PublicKey, SecretKey, Signature, SigningError, + TARGET_SUM, TREE_DEPTH, + }; + + /// Computes the public-key commitment expected by `leansig_poseidon2::verify`. + pub fn public_key_commitment(root: Word, parameter: Word) -> Word { + PublicKey::new(root, parameter).to_commitment() + } + + /// Encodes a Rust-generated signature in the format consumed by `leansig_poseidon2::verify`. + pub fn encode(public_key: &PublicKey, signature: &Signature) -> Vec { + signature.to_advice(public_key) + } + + /// Encodes a LeanSig public key and signature in verifier advice-consumption order. + /// + /// The returned sequence is + /// `[ROOT || PARAMETER || RHO || SIG_HASHES[46] || AUTH_PATH[32]]`. + pub fn encode_signature( + root: Word, + parameter: Word, + rho: Word, + signature_hashes: &[Word; DIMENSION], + authentication_path: &[Word; TREE_DEPTH], + ) -> Vec { + let mut advice = Vec::with_capacity((3 + DIMENSION + TREE_DEPTH) * 4); + advice.extend(root); + advice.extend(parameter); + advice.extend(rho); + advice.extend(signature_hashes.iter().flat_map(|word| word.iter()).copied()); + advice.extend(authentication_path.iter().flat_map(|word| word.iter()).copied()); + advice + } +} diff --git a/crates/lib/core/tests/crypto/leansig.rs b/crates/lib/core/tests/crypto/leansig.rs new file mode 100644 index 0000000000..a6898ebf0e --- /dev/null +++ b/crates/lib/core/tests/crypto/leansig.rs @@ -0,0 +1,176 @@ +use miden_assembly::{Assembler, Linkage}; +use miden_core::{Felt, Word}; +use miden_core_lib::{ + CoreLibrary, + dsa::leansig_poseidon2::{self, DIMENSION, SecretKey}, +}; +use miden_processor::{ + DefaultHost, ExecutionError, ExecutionOptions, ExecutionOutput, FastProcessor, StackInputs, + advice::{AdviceInputs, AdviceStack}, +}; +use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng}; + +const VERIFY_EXPECTED_CYCLES: u64 = 29_383; + +#[test] +fn core_leansig_poseidon2_verify_accepts_valid_signature() { + let fixture = valid_fixture(); + run_verify(&fixture).expect("valid LeanSig signature must verify"); +} + +#[test] +fn core_leansig_poseidon2_verify_cycle_baseline() { + let fixture = valid_fixture(); + let source = format!( + r#" + begin + push.{} + {} + {} + clk movdn.9 + exec.::miden::core::crypto::dsa::leansig_poseidon2::verify + clk swap sub + swap.15 drop movup.14 + end + "#, + fixture.epoch, + masm_push_word(&fixture.message), + masm_push_word(&fixture.pk_comm), + ); + let output = run_program(&source, &fixture.advice).expect("cycle baseline must verify"); + let cycles = output.stack.get_element(0).expect("cycle count").as_canonical_u64(); + assert_eq!(cycles, VERIFY_EXPECTED_CYCLES); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_wrong_public_key_commitment() { + let mut fixture = valid_fixture(); + tamper_felt(&mut fixture.pk_comm[0]); + run_verify(&fixture).expect_err("wrong public key commitment must trap"); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_wrong_message() { + let mut fixture = valid_fixture(); + tamper_felt(&mut fixture.message[0]); + run_verify(&fixture).expect_err("wrong message must trap"); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_wrong_epoch() { + let mut fixture = valid_fixture(); + fixture.epoch ^= 1; + run_verify(&fixture).expect_err("wrong epoch must trap"); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_non_u32_epoch() { + let fixture = valid_fixture(); + run_verify_with_epoch(&fixture, 1u64 << 32).expect_err("non-u32 epoch must trap"); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_tampered_chain_hash() { + let mut fixture = valid_fixture(); + let first_signature_hash = 3 * 4; + tamper_felt(&mut fixture.advice[first_signature_hash]); + run_verify(&fixture).expect_err("tampered Winternitz hash must trap"); +} + +#[test] +fn core_leansig_poseidon2_verify_traps_on_tampered_authentication_path() { + let mut fixture = valid_fixture(); + let first_path_node = (3 + DIMENSION) * 4; + tamper_felt(&mut fixture.advice[first_path_node]); + run_verify(&fixture).expect_err("tampered authentication path must trap"); +} + +struct Fixture { + pk_comm: Word, + message: Word, + epoch: u32, + advice: Vec, +} + +fn valid_fixture() -> Fixture { + let mut rng = ChaCha20Rng::from_seed([0x51; 32]); + let epoch = 0x1020_3040; + let message = Word::new([1u32, 2, 3, 4].map(Felt::from_u32)); + let mut secret_key = SecretKey::with_rng(&mut rng, epoch, 1).expect("LeanSig key generation"); + let public_key = secret_key.public_key(); + let signature = secret_key.sign(epoch, message).expect("LeanSig signing"); + assert!(public_key.verify(epoch, message, &signature)); + + let pk_comm = public_key.to_commitment(); + let advice = leansig_poseidon2::encode(&public_key, &signature); + + Fixture { pk_comm, message, epoch, advice } +} + +fn run_verify(fixture: &Fixture) -> Result { + run_verify_with_epoch(fixture, u64::from(fixture.epoch)) +} + +fn run_verify_with_epoch(fixture: &Fixture, epoch: u64) -> Result { + let source = format!( + r#" + begin + push.{} + {} + {} + exec.::miden::core::crypto::dsa::leansig_poseidon2::verify + end + "#, + epoch, + masm_push_word(&fixture.message), + masm_push_word(&fixture.pk_comm), + ); + + let output = run_program(&source, &fixture.advice); + if let Ok(output) = &output { + assert!(output.advice.stack().is_empty(), "LeanSig verifier must consume its advice"); + } + output +} + +fn run_program(source: &str, advice: &[Felt]) -> Result { + let core_lib = CoreLibrary::default(); + let program = Assembler::default() + .with_package(core_lib.package(), Linkage::Dynamic) + .expect("failed to link core library") + .assemble_program("core_leansig_poseidon2_test", source) + .expect("failed to assemble LeanSig test program") + .unwrap_program(); + + let mut host = DefaultHost::default() + .with_library(&core_lib) + .expect("failed to load CoreLibrary into the host"); + let mut advice_stack = AdviceStack::new(); + advice_stack.append_elements(advice.iter().copied()); + let processor = FastProcessor::new_with_options( + StackInputs::default(), + AdviceInputs::default().with_advice_stack(advice_stack), + ExecutionOptions::default(), + ) + .expect("processor construction"); + + processor.execute_sync(&program, &mut host) +} + +fn masm_push_word(word: &Word) -> String { + let felts = word + .iter() + .rev() + .map(|felt| felt.as_canonical_u64().to_string()) + .collect::>() + .join("."); + format!("push.{felts}") +} + +fn tamper_felt(felt: &mut Felt) { + *felt = if *felt == Felt::ZERO { + Felt::ONE + } else { + Felt::new_unchecked(felt.as_canonical_u64() - 1) + }; +} diff --git a/crates/lib/core/tests/crypto/mod.rs b/crates/lib/core/tests/crypto/mod.rs index 6c8892f918..c0b5c44a90 100644 --- a/crates/lib/core/tests/crypto/mod.rs +++ b/crates/lib/core/tests/crypto/mod.rs @@ -1,5 +1,6 @@ mod dsa; mod falcon; +mod leansig; mod aead; mod blake3; diff --git a/crates/lib/core/tests/main.rs b/crates/lib/core/tests/main.rs index 7dcb9bcc68..855e43606c 100644 --- a/crates/lib/core/tests/main.rs +++ b/crates/lib/core/tests/main.rs @@ -113,6 +113,7 @@ fn core_library_exports_crypto_wrappers() { "::miden::core::crypto::hashes::keccak256::hash", "::miden::core::crypto::hashes::keccak256::merge", "::miden::core::crypto::dsa::ecdsa_k256_keccak::verify", + "::miden::core::crypto::dsa::leansig_poseidon2::verify", ] { assert!( package.get_procedure_root_by_path(path).is_some(), diff --git a/docs/src/user_docs/core_lib/crypto/dsa.md b/docs/src/user_docs/core_lib/crypto/dsa.md index c0e9fe8ad7..ef596c61d3 100644 --- a/docs/src/user_docs/core_lib/crypto/dsa.md +++ b/docs/src/user_docs/core_lib/crypto/dsa.md @@ -7,6 +7,55 @@ sidebar_position: 1 Namespace `miden::core::crypto::dsa` contains core-library signature procedures. +## LeanSig Poseidon2 + +Module `miden::core::crypto::dsa::leansig_poseidon2` verifies a Miden-native instantiation of +LeanSig's generalized XMSS construction. The MASM module is a verifier only; the matching +stateful Rust key generator and signer are available from `miden_crypto::dsa::leansig_poseidon2`. +The Rust secret key persists a monotonic epoch/nonce cursor and rejects reused or skipped epochs. + +This instantiation preserves the reference construction's lifetime-$2^{32}$ target-sum parameters +($v = 46$, $w = 8$, and target sum $T = 200$), while replacing its KoalaBear Poseidon1 hash with +Miden's native Goldilocks Poseidon2 permutation. Consequently, it is not wire-compatible with the +current `leanEthereum/leanSig` Rust instantiation. The hash boundary is deliberately isolated so a +future Blake3 instantiation can retain the XMSS verification flow and advice layout. +Like the reference implementation, the Rust signer uses SHAKE128 as its host-side secret-key PRF +for one-time chain starts and deterministic encoding randomness. + +The module exposes the following procedure: + +| Procedure | Description | +|-----------|-------------| +| `verify` | Verifies a fixed-parameter LeanSig signature and traps on failure.

**Stack inputs:** `[PK_COMM, MSG, EPOCH, ...]`
**Advice stack inputs:** `[ROOT, PARAMETER, RHO, SIG_HASHES[46], AUTH_PATH[32], ...]`
**Outputs:** `[...]`

`PK_COMM` binds `ROOT` and `PARAMETER`; `MSG`, `ROOT`, `PARAMETER`, `RHO`, each signature hash, and each authentication-path node are words. `EPOCH` is a canonical `u32`. Advice is consumed in the displayed structural order. | + +### Instantiation and hash specification + +- Lifetime: $2^{32}$ epochs, with a fixed 32-node authentication path. +- Incomparable encoding: target-sum Winternitz code with dimension 46, base 8, and target sum 200. +- Public key: `(ROOT, PARAMETER)`, committed as a domain-separated Poseidon2 merge. +- Message hash: a two-block replacement sponge. The first permutation absorbs `(MSG, PARAMETER)` + with a capacity word containing the message-hash domain and `EPOCH`; the second replaces the + rate with `(RHO, 0)` and permutes again. +- Chunk extraction: the first five rate elements are rejection-sampled using + $p = Q \cdot 8^{10} + 1$, where $p = 2^{64} - 2^{32} + 1$ and + $Q = 17\,179\,869\,180$. Each accepted element yields ten base-8 digits; the first 46 digits + form the codeword and must sum to 200. +- Chain hash: Poseidon2 over `(CURRENT, PARAMETER)` with a capacity word containing the chain + domain, `EPOCH`, chain index, and one-based chain position. +- Leaf hash: a replacement sponge over the 46 chain endpoints, with a capacity word containing + the leaf domain, `EPOCH`, and dimension. +- Internal-node hash: Poseidon2 over `(LEFT, RIGHT)` with a capacity word containing the tree + domain, level, and parent position. + +The capacity-domain identifiers are fixed as follows: public-key commitment `1`, message `2`, +chain `3`, leaf `4`, and internal tree node `5`. A signature consumes 324 advice elements: three +words for `ROOT`, `PARAMETER`, and `RHO`; 46 chain words; and 32 authentication-path words. The +current cycle baseline for `verify`, excluding input setup, is 29,383 VM cycles. + +The construction and this implementation have not been independently audited. In particular, +changing the hash layouts, domain constants, encoding parameters, or advice order defines a +different signature scheme. + ## Poseidon2 Falcon512 Module `miden::core::crypto::dsa::falcon512_poseidon2` contains procedures for verifying diff --git a/docs/src/user_docs/core_lib/index.md b/docs/src/user_docs/core_lib/index.md index 071dca93bd..c337deda74 100644 --- a/docs/src/user_docs/core_lib/index.md +++ b/docs/src/user_docs/core_lib/index.md @@ -39,6 +39,7 @@ Currently, Miden core library contains just a few modules, which are listed belo | [miden::core::crypto::aead](./crypto/aead.md) | Contains procedures for authenticated encryption with associated data (AEAD) using Poseidon2 hash. | | [miden::core::crypto::dsa::ecdsa_k256_keccak](./crypto/dsa.md#ecdsa-secp256k1-keccak256) | Proves the existence of an ECDSA-valid secp256k1/Keccak256 advice witness; it does not bind a canonical Ethereum signature encoding. | | [miden::core::crypto::dsa::falcon512_poseidon2](./crypto/dsa.md#poseidon2-falcon512) | Contains procedures for verifying Poseidon2 Falcon512 post-quantum signatures. | +| [miden::core::crypto::dsa::leansig_poseidon2](./crypto/dsa.md#leansig-poseidon2) | Verifies the Miden-native Poseidon2 instantiation of the LeanSig generalized-XMSS scheme. | | [miden::core::crypto::hashes::blake3](./crypto/hashes.md#blake3) | Contains procedures for computing hashes using BLAKE3 hash function. | | [miden::core::crypto::hashes::keccak256](./crypto/hashes.md#keccak256) | Contains procedures for computing hashes using Keccak256. | | [miden::core::crypto::hashes::poseidon2](./crypto/hashes.md#poseidon2) | Contains procedures for computing hashes using the Poseidon2 hash function. |