diff --git a/Cargo.lock b/Cargo.lock index f493b7c34..ec121db56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -896,6 +896,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "unicode-normalization", + "zeroize", ] [[package]] @@ -7542,24 +7543,23 @@ checksum = "55e32db7b3a5d70086f82f198ecfc021f17cb7ec70a416512dba3488f4a116c1" [[package]] name = "qp-rusty-crystals-dilithium" -version = "3.0.1" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0f6eb664c03395b3892f7f18d4f08bff0e256a967bb0146598a0f771d5e99ba" +checksum = "0799fbe9bf179087eae23c9ba89c1f6c62228a4afdb999c5c0975a0f17c3c5bf" dependencies = [ "zeroize", ] [[package]] name = "qp-rusty-crystals-hdwallet" -version = "3.0.1" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9a9d975e8bfa2b6726f9668f32bd134a2556ccf65f4d336fed941658e4db67" +checksum = "6865ff92884da31f4a6a38751f393f24107b72a8610e61e26cda8add47479520" dependencies = [ "bip39", "getrandom 0.2.17", "hex", "hex-literal 0.4.1", - "hmac 0.12.1", "qp-poseidon-core", "qp-rusty-crystals-dilithium", "serde", diff --git a/Cargo.toml b/Cargo.toml index 9a383ec39..fb2c82604 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -231,8 +231,8 @@ sp-consensus-qpow = { path = "./primitives/consensus/qpow", default-features = f qp-plonky2 = { version = "1.5.4", default-features = false } qp-plonky2-verifier = { version = "1.5.4", default-features = false } qp-poseidon-core = { version = "3.0.2", default-features = false } -qp-rusty-crystals-dilithium = { version = "3.0.1", default-features = false } -qp-rusty-crystals-hdwallet = { version = "3.0.1" } +qp-rusty-crystals-dilithium = { version = "4.0.0", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] } +qp-rusty-crystals-hdwallet = { version = "4.0.0", features = ["ml-dsa-65"] } qp-wormhole-aggregator = { version = "3.1.0", default-features = false } qp-wormhole-circuit = { version = "3.1.0", default-features = false } qp-wormhole-circuit-builder = { version = "3.1.0", default-features = false } diff --git a/client/cli/src/arg_enums.rs b/client/cli/src/arg_enums.rs index 4a0bfa035..2723a0ce3 100644 --- a/client/cli/src/arg_enums.rs +++ b/client/cli/src/arg_enums.rs @@ -122,8 +122,11 @@ pub enum NodeKeyType { #[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)] #[value(rename_all = "kebab-case")] pub enum CryptoScheme { - /// Use dilithium. - Dilithium, + /// Use dilithium ML-DSA-87. + #[value(alias = "dilithium")] + Dilithium87, + /// Use dilithium ML-DSA-65. + Dilithium65, } /// The type of the output format. diff --git a/client/cli/src/commands/inspect_key.rs b/client/cli/src/commands/inspect_key.rs index c2aff16f3..98dc4098b 100644 --- a/client/cli/src/commands/inspect_key.rs +++ b/client/cli/src/commands/inspect_key.rs @@ -151,7 +151,7 @@ fn expect_public_from_phrase( #[cfg(test)] mod tests { use super::*; - use qp_dilithium_crypto::DilithiumPair; + use qp_dilithium_crypto::Dilithium87Pair; use sp_core::crypto::{ByteArray, Pair, Ss58AddressFormat}; use sp_runtime::traits::IdentifyAccount; @@ -205,7 +205,7 @@ mod tests { let uri = SecretUri::from_str(seed_phrase).expect("Valid URI"); let password: Option<&str> = uri.password.as_ref().map(|s| s.expose_secret().as_ref()); - let pair: DilithiumPair = + let pair: Dilithium87Pair = Pair::from_string(uri.phrase.expose_secret().as_str(), password).expect("Valid"); let public = pair.public(); let public_hex = array_bytes::bytes2hex("0x", public.as_slice()); diff --git a/client/cli/src/commands/utils.rs b/client/cli/src/commands/utils.rs index fa2bb1084..6b45e86ae 100644 --- a/client/cli/src/commands/utils.rs +++ b/client/cli/src/commands/utils.rs @@ -290,8 +290,11 @@ macro_rules! with_crypto_scheme { $method:ident<$($generics:ty),*>( $( $params:expr ),* $(,)?) $(,)? ) => { match $scheme { - $crate::CryptoScheme::Dilithium => { - $method::($($params),*) + $crate::CryptoScheme::Dilithium87 => { + $method::($($params),*) + } + $crate::CryptoScheme::Dilithium65 => { + $method::($($params),*) } } }; diff --git a/client/cli/src/commands/verify.rs b/client/cli/src/commands/verify.rs index 5043e605c..13f764839 100644 --- a/client/cli/src/commands/verify.rs +++ b/client/cli/src/commands/verify.rs @@ -160,7 +160,7 @@ mod test { fn sign_then_verify_roundtrip() { // Derive public key from mnemonic let mnemonic_pair = - utils::pair_from_suri::(MNEMONIC, None) + utils::pair_from_suri::(MNEMONIC, None) .expect("Must derive pair from mnemonic"); let public_hex = format!("0x{}", hex::encode(mnemonic_pair.public().as_ref())); // Sign via the sign command diff --git a/client/cli/src/params/mod.rs b/client/cli/src/params/mod.rs index b41e16f6d..a742b4fce 100644 --- a/client/cli/src/params/mod.rs +++ b/client/cli/src/params/mod.rs @@ -136,7 +136,7 @@ impl BlockNumberOrHash { #[derive(Debug, Clone, Args)] pub struct CryptoSchemeFlag { /// cryptography scheme - #[arg(long, value_name = "SCHEME", value_enum, ignore_case = true, default_value_t = CryptoScheme::Dilithium)] + #[arg(long, value_name = "SCHEME", value_enum, ignore_case = true, default_value_t = CryptoScheme::Dilithium87)] pub scheme: CryptoScheme, } diff --git a/client/litep2p/src/crypto/dilithium.rs b/client/litep2p/src/crypto/dilithium.rs index 682c62aa9..25a272cce 100644 --- a/client/litep2p/src/crypto/dilithium.rs +++ b/client/litep2p/src/crypto/dilithium.rs @@ -69,8 +69,8 @@ impl Keypair { /// Derive the internal keypair from the seed. fn derive_internal(&self) -> ml_dsa_87::Keypair { let mut seed_copy = self.seed; - let sensitive_seed = SensitiveBytes32::from(&mut seed_copy); - ml_dsa_87::Keypair::generate(sensitive_seed) + let mut sensitive_seed = SensitiveBytes32::from(&mut seed_copy); + ml_dsa_87::Keypair::generate(&mut sensitive_seed) } /// Convert the keypair into a byte array. @@ -121,7 +121,7 @@ impl Keypair { /// Get the public key of this keypair. pub fn public(&self) -> PublicKey { - PublicKey(self.derive_internal().public) + PublicKey(self.derive_internal().public().clone()) } /// Get the secret key (seed) of this keypair. diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index be8436493..5638e23f8 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -11,7 +11,7 @@ genesis logic, and the workspace primitive crates pulled in. - **Build:** `no_std` WASM via `substrate-wasm-builder` (`runtime/build.rs`); native `std` build for the node/client - **Block time target:** 12s (`TARGET_BLOCK_TIME_MS = 12_000`) - **Consensus:** QPoW (quantum-resistant Proof of Work, Poseidon2-based) -- **Signatures:** Dilithium (ML-DSA-87) post-quantum signature scheme +- **Signatures:** Dilithium post-quantum signature schemes (ML-DSA-87 and ML-DSA-65) - **SS58 prefix:** 189 --- @@ -273,7 +273,7 @@ Related transaction-payment RPC surface (patched for WASM + node builds): | Crate | Path | Role in runtime | | --- | --- | --- | -| `qp-dilithium-crypto` | `primitives/dilithium-crypto` | ML-DSA-87 post-quantum signatures; `DilithiumSignatureScheme` = the chain's `Signature`/`AccountId`. | +| `qp-dilithium-crypto` | `primitives/dilithium-crypto` | ML-DSA-87/ML-DSA-65 post-quantum signatures; `DilithiumSignatureScheme` = the chain's `Signature`/`AccountId`. | | `qp-header` | `primitives/header` | Custom block `Header` (Poseidon block hash + Blake2 state trie); `ZkTreeRootProvider` trait. | | `qp-high-security` | `primitives/high-security` | `HighSecurityInspector` trait shared by multisig, reversible-transfers, tx-extensions (breaks circular dep). | | `qp-scheduler` | `primitives/scheduler` | `BlockNumberOrTimestamp`, `DispatchTime`, `ScheduleNamed` trait for delayed dispatch. | diff --git a/node/src/benchmarking.rs b/node/src/benchmarking.rs index 2dd9a8ef8..cd1e76cee 100644 --- a/node/src/benchmarking.rs +++ b/node/src/benchmarking.rs @@ -98,7 +98,7 @@ impl frame_benchmarking_cli::ExtrinsicBuilder for TransferKeepAliveBuilder { /// Note: Should only be used for benchmarking. pub fn create_benchmark_extrinsic( client: &FullClient, - sender: qp_dilithium_crypto::DilithiumPair, + sender: qp_dilithium_crypto::Dilithium87Pair, call: runtime::RuntimeCall, nonce: u32, ) -> runtime::UncheckedExtrinsic { @@ -154,7 +154,7 @@ pub fn create_benchmark_extrinsic( runtime::UncheckedExtrinsic::new_signed( call, sender.public().into_account().into(), - runtime::Signature::Dilithium(signature), + runtime::Signature::Dilithium87(signature), tx_ext, ) } diff --git a/node/src/command.rs b/node/src/command.rs index af7367187..5719b7a80 100644 --- a/node/src/command.rs +++ b/node/src/command.rs @@ -7,10 +7,10 @@ use crate::{ }; #[cfg(feature = "runtime-benchmarks")] use frame_benchmarking_cli::{BenchmarkCmd, ExtrinsicFactory, SUBSTRATE_REFERENCE_HARDWARE}; -use qp_dilithium_crypto::{traits::WormholeAddress, DilithiumPair}; +use qp_dilithium_crypto::{traits::WormholeAddress, Dilithium87Pair}; use qp_rusty_crystals_hdwallet::{ derive_key_from_mnemonic, derive_wormhole_from_mnemonic, generate_mnemonic, mnemonic_to_seed, - wormhole::WormholePair, SensitiveBytes32, QUANTUS_DILITHIUM_CHAIN_ID, + wormhole::WormholePair, SensitiveBytes32, SensitiveBytes64, QUANTUS_DILITHIUM_CHAIN_ID, QUANTUS_WORMHOLE_CHAIN_ID, }; use quantus_runtime::Block; @@ -112,14 +112,13 @@ pub fn generate_quantus_key( // Use provided mnemonic. The caller already knows it, so it is // deliberately NOT placed in `secret_phrase` for echoing back. if no_derivation { - // Get raw seed from mnemonic (mnemonic_to_seed zeroizes its owned copy). - let mut seed64 = - mnemonic_to_seed(words_phrase.to_string(), None).map_err(|e| { - eprintln!("Error processing provided words: {:?}", e); - sc_cli::Error::Input("Failed to process provided words".into()) - })?; - seed_for_pair = Zeroizing::new(seed64.to_vec()); - seed64.zeroize(); + // Get raw seed from mnemonic (writes into `seed64`, which zeroizes on drop). + let mut seed64 = SensitiveBytes64::zeroed(); + mnemonic_to_seed(words_phrase.to_string(), None, &mut seed64).map_err(|e| { + eprintln!("Error processing provided words: {:?}", e); + sc_cli::Error::Input("Failed to process provided words".into()) + })?; + seed_for_pair = Zeroizing::new(seed64.as_bytes().to_vec()); } else { println!("Deriving HD path: {}", path); let keypair = @@ -127,7 +126,7 @@ pub fn generate_quantus_key( eprintln!("Error deriving from mnemonic: {:?}", e); sc_cli::Error::Input("Failed to derive from mnemonic".into()) })?; - let dilithium_pair = DilithiumPair::from_keypair(keypair); + let dilithium_pair = Dilithium87Pair::from_keypair(keypair); let account_id = AccountId32::from(dilithium_pair.public()); return Ok(QuantusKeyDetails { address: account_id @@ -172,13 +171,13 @@ pub fn generate_quantus_key( words_to_print = Some(new_words.clone()); if no_derivation { - // Get raw seed from mnemonic (consumes and zeroizes `new_words`). - let mut seed64 = mnemonic_to_seed(new_words, None).map_err(|e| { + // Get raw seed from mnemonic (writes into `seed64`, which zeroizes on drop). + let mut seed64 = SensitiveBytes64::zeroed(); + mnemonic_to_seed(new_words, None, &mut seed64).map_err(|e| { eprintln!("Error converting mnemonic to seed: {:?}", e); sc_cli::Error::Input("Failed to convert mnemonic to seed".into()) })?; - seed_for_pair = Zeroizing::new(seed64.to_vec()); - seed64.zeroize(); + seed_for_pair = Zeroizing::new(seed64.as_bytes().to_vec()); } else { println!("Deriving HD path: {}", path); let keypair = @@ -188,7 +187,7 @@ pub fn generate_quantus_key( })?; let mut new_words = new_words; new_words.zeroize(); - let dilithium_pair = DilithiumPair::from_keypair(keypair); + let dilithium_pair = Dilithium87Pair::from_keypair(keypair); let account_id = AccountId32::from(dilithium_pair.public()); return Ok(QuantusKeyDetails { address: account_id @@ -203,8 +202,8 @@ pub fn generate_quantus_key( } }; - let dilithium_pair = DilithiumPair::from_seed(&seed_for_pair).map_err(|e| { - eprintln!("Error creating DilithiumPair: {:?}", e); + let dilithium_pair = Dilithium87Pair::from_seed(&seed_for_pair).map_err(|e| { + eprintln!("Error creating Dilithium87Pair: {:?}", e); sc_cli::Error::Input("Failed to create keypair".into()) })?; @@ -244,14 +243,15 @@ pub fn generate_quantus_key( }; let wormhole_pair = if no_derivation { - let mut seed64 = mnemonic_to_seed(words_phrase.to_string(), None).map_err(|e| { + let mut seed64 = SensitiveBytes64::zeroed(); + mnemonic_to_seed(words_phrase.to_string(), None, &mut seed64).map_err(|e| { eprintln!("Error processing provided words: {:?}", e); sc_cli::Error::Input("Failed to process provided words".into()) })?; let mut seed32 = [0u8; 32]; - seed32.copy_from_slice(&seed64[..32]); - seed64.zeroize(); - WormholePair::generate_new(SensitiveBytes32::from(&mut seed32)) + seed32.copy_from_slice(&seed64.as_bytes()[..32]); + let mut sensitive_seed = SensitiveBytes32::from(&mut seed32); + WormholePair::generate_new(&mut sensitive_seed) } else { println!("Deriving wormhole HD path: {}", path); derive_wormhole_from_mnemonic(&words_phrase, None, &path).map_err(|e| { diff --git a/primitives/dilithium-crypto/src/lib.rs b/primitives/dilithium-crypto/src/lib.rs index 31d4befb3..e8efa3981 100644 --- a/primitives/dilithium-crypto/src/lib.rs +++ b/primitives/dilithium-crypto/src/lib.rs @@ -3,6 +3,7 @@ extern crate alloc; pub mod pair; +mod scheme_macro; pub mod traits; pub mod types; @@ -13,8 +14,9 @@ pub const SECRET_KEY_BYTES: usize = ml_dsa_87::SECRETKEYBYTES; pub const SIGNATURE_BYTES: usize = ml_dsa_87::SIGNBYTES; pub use pair::{create_keypair, crystal_alice, crystal_charlie, dilithium_bob, generate}; -pub use traits::verify; pub use types::{ - DilithiumPair, DilithiumPublic, DilithiumSignature, DilithiumSignatureScheme, - DilithiumSignatureWithPublic, DilithiumSigner, WrappedPublicBytes, WrappedSignatureBytes, + verify_ml_dsa_65, verify_ml_dsa_87, Dilithium65CryptoTag, Dilithium65Pair, Dilithium65Public, + Dilithium65Signature, Dilithium65SignatureWithPublic, Dilithium87CryptoTag, Dilithium87Pair, + Dilithium87Public, Dilithium87Signature, Dilithium87SignatureWithPublic, + DilithiumSignatureScheme, DilithiumSigner, WrappedPublicBytes, WrappedSignatureBytes, }; diff --git a/primitives/dilithium-crypto/src/pair.rs b/primitives/dilithium-crypto/src/pair.rs index dfb56c3f6..640129645 100644 --- a/primitives/dilithium-crypto/src/pair.rs +++ b/primitives/dilithium-crypto/src/pair.rs @@ -1,187 +1,22 @@ -use crate::{DilithiumSignatureScheme, DilithiumSignatureWithPublic, DilithiumSigner}; - -use super::types::{DilithiumPair, DilithiumPublic}; -use alloc::vec::Vec; +use super::types::Dilithium87Pair; use qp_rusty_crystals_dilithium::{ ml_dsa_87::{Keypair, PublicKey, SecretKey}, params::SEEDBYTES, SensitiveBytes32, }; -use sp_core::{ - crypto::{DeriveError, DeriveJunction, SecretStringError}, - ByteArray, Pair, -}; -use sp_runtime::{ - traits::{IdentifyAccount, Verify}, - AccountId32, -}; +use sp_core::Pair; -pub fn crystal_alice() -> DilithiumPair { +pub fn crystal_alice() -> Dilithium87Pair { let seed = [0u8; 32]; - DilithiumPair::from_seed_slice(&seed).expect("Always succeeds") + Dilithium87Pair::from_seed_slice(&seed).expect("Always succeeds") } -pub fn dilithium_bob() -> DilithiumPair { +pub fn dilithium_bob() -> Dilithium87Pair { let seed = [1u8; 32]; - DilithiumPair::from_seed_slice(&seed).expect("Always succeeds") + Dilithium87Pair::from_seed_slice(&seed).expect("Always succeeds") } -pub fn crystal_charlie() -> DilithiumPair { +pub fn crystal_charlie() -> Dilithium87Pair { let seed = [2u8; 32]; - DilithiumPair::from_seed_slice(&seed).expect("Always succeeds") -} - -impl IdentifyAccount for DilithiumPair { - type AccountId = AccountId32; - fn into_account(self) -> AccountId32 { - self.public().into_account() - } -} - -impl Pair for DilithiumPair { - type Public = DilithiumPublic; - type Seed = [u8; 32]; - type Signature = DilithiumSignatureWithPublic; - type ProofOfPossession = DilithiumSignatureWithPublic; - - // Dilithium doesn't support path-based derivation - use hdwallet in qp-rusty-crystals instead. - // However, an empty path is a valid no-op that returns self unchanged. - fn derive>( - &self, - path_iter: Iter, - seed: Option<::Seed>, - ) -> Result<(Self, Option<::Seed>), DeriveError> { - // Check if any junctions are present - empty path is a no-op - let mut path_iter = path_iter.peekable(); - if path_iter.peek().is_none() { - return Ok((self.clone(), seed)); - } - - log::error!( - "Pair::derive() is not supported for Dilithium. \ - Use qp_rusty_crystals_hdwallet::derive_key_from_mnemonic() for HD key derivation." - ); - // SoftKeyInPath is the only error available for this trait definition. - Err(DeriveError::SoftKeyInPath) - } - - fn from_seed_slice(seed: &[u8]) -> Result { - DilithiumPair::from_seed(seed).map_err(|_| SecretStringError::InvalidSeed) - } - - #[cfg(feature = "full_crypto")] - fn sign(&self, message: &[u8]) -> DilithiumSignatureWithPublic { - // Create keypair struct - - use crate::types::DilithiumSignature; - let keypair = create_keypair(&self.public, &self.secret).expect("Failed to create keypair"); - - // Sign the message - let signature = keypair.sign(message, None, None).expect("Signing should not fail"); - - let signature = - DilithiumSignature::try_from(signature.as_ref()).expect("Wrap doesn't fail"); - - DilithiumSignatureWithPublic::new(signature, self.public()) - } - - fn verify>( - sig: &DilithiumSignatureWithPublic, - message: M, - pubkey: &DilithiumPublic, - ) -> bool { - let sig_scheme = DilithiumSignatureScheme::Dilithium(sig.clone()); - let signer = DilithiumSigner::Dilithium(pubkey.clone()); - sig_scheme.verify(message.as_ref(), &signer.into_account()) - } - - fn public(&self) -> Self::Public { - DilithiumPublic::from_slice(&self.public).expect("Valid public key bytes") - } - - fn to_raw_vec(&self) -> Vec { - // this is modeled after sr25519 which returns the private key for this method - self.secret.to_vec() - } - - // NOTE: This method does not parse all secret uris correctly, like - // "mnemonic///password///account" This was supported in standard substrate, if there is - // demand, we can support it in the future - fn from_string(s: &str, password_override: Option<&str>) -> Result { - let res = Self::from_phrase(s, password_override) - .map_err(|_| SecretStringError::InvalidPhrase)?; - Ok(res.0) - } - - #[cfg(feature = "std")] - fn from_phrase( - phrase: &str, - password: Option<&str>, - ) -> Result<(Self, Self::Seed), SecretStringError> { - use qp_rusty_crystals_hdwallet::{ - hderive::ExtendedPrivKey, mnemonic_to_seed, SensitiveBytes64, - }; - // Default derivation path for Quantus: m/44'/189189'/0'/0'/0' - const DEFAULT_PATH: &str = "m/44'/189189'/0'/0'/0'"; - let mut seed_bytes = mnemonic_to_seed(phrase.to_string(), password) - .map_err(|_| SecretStringError::InvalidPhrase)?; - // Wrap the BIP39 seed so that both the stack original (wiped by `from`) and the - // wrapped copy (`ZeroizeOnDrop`) are zeroized once derivation is done. - let seed_bytes = SensitiveBytes64::from(&mut seed_bytes); - // The returned seed must be the actual entropy of the returned pair, so that - // `from_seed(seed)` reconstructs the very same account (users back up the - // displayed seed as recovery material). That entropy is the 32-byte secret - // derived at the default HD path: `derive_key_from_mnemonic` internally runs - // `Keypair::generate` on exactly these bytes, which is also what `from_seed` - // does. - let xpriv = ExtendedPrivKey::derive(seed_bytes.as_bytes(), DEFAULT_PATH) - .map_err(|_| SecretStringError::InvalidPath)?; - let seed = xpriv.secret(); - let pair = DilithiumPair::from_seed(&seed).map_err(|_| SecretStringError::InvalidSeed)?; - Ok((pair, seed)) - } - - #[cfg(feature = "std")] - fn from_string_with_seed( - s: &str, - password: Option<&str>, - ) -> Result<(Self, Option), SecretStringError> { - let (pair, seed) = Self::from_phrase(s, password)?; - Ok((pair, Some(seed))) - } -} - -#[cfg(feature = "std")] -impl DilithiumPublic { - /// Attempt to parse a Dilithium public key from a string and return it with the - /// associated SS58 address format (version). - /// - /// This inherent method is provided to avoid relying solely on the generic Ss58Codec - /// behavior which expects SS58-encoded keys of the same length as the public key. - /// For Dilithium, we primarily support hex-encoded public keys (0x-prefixed) here. - /// - /// Note: SS58 AccountId32 addresses are not convertible back into Dilithium public - /// keys. The CLI already includes a fallback to parse AccountId32 addresses when - /// this function returns an error. - pub fn from_string_with_version( - s: &str, - ) -> Result<(Self, sp_core::crypto::Ss58AddressFormat), sp_core::crypto::PublicError> { - use sp_core::crypto::{default_ss58_version, PublicError}; - // Accept 0x-prefixed hex of the raw Dilithium public key bytes. - let maybe_hex = s.strip_prefix("0x").unwrap_or(s); - // Expect exact hex length for a Dilithium public key - let expected_hex_len = ::LEN * 2; - if maybe_hex.len() == expected_hex_len && maybe_hex.chars().all(|c| c.is_ascii_hexdigit()) { - let mut bytes = vec![0u8; ::LEN]; - for (i, chunk) in maybe_hex.as_bytes().chunks(2).enumerate() { - let h = (chunk[0] as char).to_digit(16).ok_or(PublicError::InvalidFormat)? as u8; - let l = (chunk[1] as char).to_digit(16).ok_or(PublicError::InvalidFormat)? as u8; - bytes[i] = (h << 4) | l; - } - let pk = ::from_slice(&bytes).map_err(|_| PublicError::BadLength)?; - return Ok((pk, default_ss58_version())); - } - // Not a supported Dilithium public key representation here. - Err(PublicError::InvalidFormat) - } + Dilithium87Pair::from_seed_slice(&seed).expect("Always succeeds") } /// Generates a new Dilithium ML-DSA-87 keypair @@ -204,8 +39,8 @@ pub fn generate(entropy: &[u8]) -> Result { } let mut entropy_array = [0u8; 32]; entropy_array.copy_from_slice(&entropy[..32]); - let sensitive_entropy = SensitiveBytes32::from(&mut entropy_array); - Ok(Keypair::generate(sensitive_entropy)) + let mut sensitive_entropy = SensitiveBytes32::from(&mut entropy_array); + Ok(Keypair::generate(&mut sensitive_entropy)) } /// Creates a keypair from existing public and secret key bytes @@ -228,13 +63,17 @@ pub fn create_keypair( let public = PublicKey::from_bytes(public_key).map_err(|_| crate::types::Error::InvalidPublicKey)?; - let keypair = Keypair { secret, public }; + // from_parts also validates that the public key corresponds to the secret. + let keypair = + Keypair::from_parts(secret, public).map_err(|_| crate::types::Error::InvalidPublicKey)?; Ok(keypair) } #[cfg(test)] mod tests { use super::*; + use crate::{Dilithium65Pair, Dilithium87SignatureWithPublic}; + use sp_core::ByteArray; fn setup() { // Initialize the logger once per test run @@ -248,13 +87,13 @@ mod tests { let seed = vec![0u8; 32]; - let pair = DilithiumPair::from_seed_slice(&seed).expect("Failed to create pair"); + let pair = Dilithium87Pair::from_seed_slice(&seed).expect("Failed to create pair"); let message = b"Something"; let signature = pair.sign(message); let public = pair.public(); - let result = DilithiumPair::verify(&signature, message, &public); + let result = Dilithium87Pair::verify(&signature, message, &public); assert!(result, "Signature should verify"); } @@ -262,7 +101,7 @@ mod tests { #[test] fn test_sign_different_message_fails() { let seed = [0u8; 32]; - let pair = DilithiumPair::from_seed(&seed).expect("Failed to create pair"); + let pair = Dilithium87Pair::from_seed(&seed).expect("Failed to create pair"); let message = b"Hello, world!"; let wrong_message = b"Goodbye, world!"; @@ -270,7 +109,7 @@ mod tests { let public = pair.public(); assert!( - !DilithiumPair::verify(&signature, wrong_message, &public), + !Dilithium87Pair::verify(&signature, wrong_message, &public), "Signature should not verify with wrong message" ); } @@ -278,7 +117,7 @@ mod tests { #[test] fn test_wrong_signature_fails() { let seed = [0u8; 32]; - let pair = DilithiumPair::from_seed(&seed).expect("Failed to create pair"); + let pair = Dilithium87Pair::from_seed(&seed).expect("Failed to create pair"); let message = b"Hello, world!"; let mut signature = pair.sign(message); @@ -287,12 +126,12 @@ mod tests { if let Some(byte) = signature_bytes.get_mut(0) { *byte ^= 1; } - let false_signature = DilithiumSignatureWithPublic::from_slice(signature_bytes) + let false_signature = Dilithium87SignatureWithPublic::from_slice(signature_bytes) .expect("Failed to create signature"); let public = pair.public(); assert!( - !DilithiumPair::verify(&false_signature, message, &public), + !Dilithium87Pair::verify(&false_signature, message, &public), "Corrupted signature should not verify" ); } @@ -301,8 +140,8 @@ mod tests { fn test_different_seed_different_public() { let seed1 = vec![0u8; 32]; let seed2 = vec![1u8; 32]; - let pair1 = DilithiumPair::from_seed(&seed1).expect("Failed to create pair"); - let pair2 = DilithiumPair::from_seed(&seed2).expect("Failed to create pair"); + let pair1 = Dilithium87Pair::from_seed(&seed1).expect("Failed to create pair"); + let pair2 = Dilithium87Pair::from_seed(&seed2).expect("Failed to create pair"); let pub1 = pair1.public(); let pub2 = pair2.public(); @@ -322,8 +161,8 @@ mod tests { /// their account from it with `from_seed` if the mnemonic is lost. #[test] fn test_from_phrase_returned_seed_reconstructs_same_pair() { - let (pair, seed) = DilithiumPair::from_phrase(TEST_PHRASE, None).expect("valid phrase"); - let restored = DilithiumPair::from_seed(&seed).expect("valid seed"); + let (pair, seed) = Dilithium87Pair::from_phrase(TEST_PHRASE, None).expect("valid phrase"); + let restored = Dilithium87Pair::from_seed(&seed).expect("valid seed"); assert_eq!( pair.public_bytes(), restored.public_bytes(), @@ -336,8 +175,9 @@ mod tests { #[test] fn test_from_phrase_returned_seed_reconstructs_same_pair_with_password() { let password = Some("hunter2"); - let (pair, seed) = DilithiumPair::from_phrase(TEST_PHRASE, password).expect("valid phrase"); - let restored = DilithiumPair::from_seed(&seed).expect("valid seed"); + let (pair, seed) = + Dilithium87Pair::from_phrase(TEST_PHRASE, password).expect("valid phrase"); + let restored = Dilithium87Pair::from_seed(&seed).expect("valid seed"); assert_eq!( pair.public_bytes(), restored.public_bytes(), @@ -349,9 +189,9 @@ mod tests { #[test] fn test_from_string_with_seed_returns_matching_seed() { let (pair, seed) = - DilithiumPair::from_string_with_seed(TEST_PHRASE, None).expect("valid phrase"); + Dilithium87Pair::from_string_with_seed(TEST_PHRASE, None).expect("valid phrase"); let seed = seed.expect("a faithful seed is available for mnemonic inputs"); - let restored = DilithiumPair::from_seed(&seed).expect("valid seed"); + let restored = Dilithium87Pair::from_seed(&seed).expect("valid seed"); assert_eq!(pair.public_bytes(), restored.public_bytes()); } @@ -365,8 +205,8 @@ mod tests { "m/44'/189189'/0'/0'/0'", ) .expect("valid phrase"); - let expected = DilithiumPair::from_keypair(keypair); - let (pair, _) = DilithiumPair::from_phrase(TEST_PHRASE, None).expect("valid phrase"); + let expected = Dilithium87Pair::from_keypair(keypair); + let (pair, _) = Dilithium87Pair::from_phrase(TEST_PHRASE, None).expect("valid phrase"); assert_eq!( pair.public_bytes(), expected.public_bytes(), @@ -378,7 +218,7 @@ mod tests { #[test] fn test_zeroize_clears_secret() { use zeroize::Zeroize; - let mut pair = DilithiumPair::from_seed(&[7u8; 32]).expect("valid seed"); + let mut pair = Dilithium87Pair::from_seed(&[7u8; 32]).expect("valid seed"); assert!(pair.secret_bytes().iter().any(|b| *b != 0)); pair.zeroize(); assert!(pair.secret_bytes().iter().all(|b| *b == 0)); @@ -388,17 +228,17 @@ mod tests { #[test] fn test_pair_zeroizes_on_drop() { fn assert_zeroize_on_drop() {} - assert_zeroize_on_drop::(); + assert_zeroize_on_drop::(); } #[test] fn test_from_raw_matching_keys_succeeds() { let seed = [0u8; 32]; - let pair = DilithiumPair::from_seed(&seed).expect("Failed to create pair"); + let pair = Dilithium87Pair::from_seed(&seed).expect("Failed to create pair"); let public = pair.public().as_ref().to_vec(); let secret = pair.secret_bytes().to_vec(); let restored = - DilithiumPair::from_raw(&public, &secret).expect("Matching keys should succeed"); + Dilithium87Pair::from_raw(&public, &secret).expect("Matching keys should succeed"); assert_eq!(restored.public().as_ref(), pair.public().as_ref()); } @@ -406,10 +246,107 @@ mod tests { fn test_from_raw_mismatched_keys_fails() { let seed1 = [0u8; 32]; let seed2 = [1u8; 32]; - let pair1 = DilithiumPair::from_seed(&seed1).expect("Failed to create pair1"); - let pair2 = DilithiumPair::from_seed(&seed2).expect("Failed to create pair2"); + let pair1 = Dilithium87Pair::from_seed(&seed1).expect("Failed to create pair1"); + let pair2 = Dilithium87Pair::from_seed(&seed2).expect("Failed to create pair2"); // Swap: pair1's secret with pair2's public - should fail validation - let result = DilithiumPair::from_raw(pair2.public().as_ref(), pair1.secret_bytes()); + let result = Dilithium87Pair::from_raw(pair2.public().as_ref(), pair1.secret_bytes()); + assert!(result.is_err(), "Mismatched public/secret should be rejected"); + } + + #[test] + fn test_ml_dsa_65_sign_and_verify() { + let seed = [0u8; 32]; + let pair = Dilithium65Pair::from_seed_slice(&seed).expect("Failed to create pair"); + let message = b"Something"; + let signature = pair.sign(message); + let public = pair.public(); + + assert!(Dilithium65Pair::verify(&signature, message, &public)); + } + + #[test] + fn test_ml_dsa_65_wrong_message_fails() { + let seed = [0u8; 32]; + let pair = Dilithium65Pair::from_seed(&seed).expect("Failed to create pair"); + let signature = pair.sign(b"Hello, world!"); + let public = pair.public(); + + assert!( + !Dilithium65Pair::verify(&signature, b"Goodbye, world!", &public), + "Signature should not verify with wrong message" + ); + } + + #[test] + fn test_ml_dsa_65_wrong_signature_fails() { + let seed = [0u8; 32]; + let pair = Dilithium65Pair::from_seed(&seed).expect("Failed to create pair"); + let message = b"Hello, world!"; + + let mut signature = pair.sign(message); + let signature_bytes = signature.as_mut(); + if let Some(byte) = signature_bytes.get_mut(0) { + *byte ^= 1; + } + let false_signature = crate::Dilithium65SignatureWithPublic::from_slice(signature_bytes) + .expect("Failed to create signature"); + let public = pair.public(); + + assert!( + !Dilithium65Pair::verify(&false_signature, message, &public), + "Corrupted signature should not verify" + ); + } + + #[test] + fn test_ml_dsa_65_from_raw_mismatched_keys_fails() { + let pair1 = Dilithium65Pair::from_seed(&[0u8; 32]).expect("Failed to create pair1"); + let pair2 = Dilithium65Pair::from_seed(&[1u8; 32]).expect("Failed to create pair2"); + let result = Dilithium65Pair::from_raw(pair2.public().as_ref(), pair1.secret_bytes()); assert!(result.is_err(), "Mismatched public/secret should be rejected"); } + + #[test] + fn test_schemes_produce_different_accounts() { + use sp_runtime::traits::IdentifyAccount; + + let seed = [0u8; 32]; + let pair87 = Dilithium87Pair::from_seed(&seed).expect("Failed to create 87 pair"); + let pair65 = Dilithium65Pair::from_seed(&seed).expect("Failed to create 65 pair"); + + // The security property: the same seed must not yield the same on-chain + // account under the two schemes. + assert_ne!( + pair87.public().into_account(), + pair65.public().into_account(), + "ML-DSA-87 and ML-DSA-65 keys must derive different AccountIds" + ); + } + + #[test] + fn test_from_phrase_works_for_both_schemes() { + // Well-known test mnemonic; proves HD derivation is wired up for both parameter sets + // (this is the path the CLI key commands use). + let phrase = "legal winner thank year wave sausage worth useful legal winner thank yellow"; + + let (pair87, _) = + Dilithium87Pair::from_phrase(phrase, None).expect("87 from_phrase failed"); + let (pair87_again, _) = + Dilithium87Pair::from_phrase(phrase, None).expect("87 from_phrase failed"); + assert_eq!(pair87.public(), pair87_again.public(), "87 derivation must be deterministic"); + + let (pair65, _) = + Dilithium65Pair::from_phrase(phrase, None).expect("65 from_phrase failed"); + let (pair65_again, _) = + Dilithium65Pair::from_phrase(phrase, None).expect("65 from_phrase failed"); + assert_eq!(pair65.public(), pair65_again.public(), "65 derivation must be deterministic"); + + // Same mnemonic, different parameter sets -> different key material. + assert_ne!(pair87.public().as_ref(), pair65.public().as_ref()); + + // The derived 65 pair must produce valid signatures. + let message = b"mnemonic-derived key"; + let signature = pair65.sign(message); + assert!(Dilithium65Pair::verify(&signature, message, &pair65.public())); + } } diff --git a/primitives/dilithium-crypto/src/scheme_macro.rs b/primitives/dilithium-crypto/src/scheme_macro.rs new file mode 100644 index 000000000..e2a8bf89a --- /dev/null +++ b/primitives/dilithium-crypto/src/scheme_macro.rs @@ -0,0 +1,505 @@ +/// Generates a complete Dilithium signature scheme (tag, pair, public/signature +/// aliases, signature-with-public type, trait impls and a raw verify function) +/// for one `qp_rusty_crystals_dilithium` parameter-set module (e.g. `ml_dsa_87`). +/// +/// All variants feed the shared `DilithiumSignatureScheme` / `DilithiumSigner` +/// enums through the `variant` enum case name. +macro_rules! define_dilithium_scheme { + ( + variant = $variant:ident, + tag = $tag:ident, + pair = $pair:ident, + public = $public:ident, + signature = $signature:ident, + sig_with_public = $sig_with_public:ident, + module = $module:ident, + verify_fn = $verify_fn:ident + $(,)? + ) => { + #[derive( + Clone, + Eq, + PartialEq, + Debug, + Hash, + codec::Encode, + codec::Decode, + scale_info::TypeInfo, + Ord, + PartialOrd, + codec::DecodeWithMemTracking, + )] + pub struct $tag; + + /// Dilithium cryptographic key pair + /// + /// Contains both secret and public key material for this parameter set. + /// + /// The secret key material is zeroized when an instance (including any clone) is + /// dropped, so released copies do not leave private-key bytes behind in memory. + #[derive(Clone, Eq, PartialEq, zeroize::Zeroize, zeroize::ZeroizeOnDrop)] + pub struct $pair { + pub(crate) secret: [u8; qp_rusty_crystals_dilithium::$module::SECRETKEYBYTES], + pub(crate) public: [u8; qp_rusty_crystals_dilithium::$module::PUBLICKEYBYTES], + } + + impl alloc::fmt::Debug for $pair { + fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { + f.debug_struct(stringify!($pair)) + .field("public", &self.public) + .finish_non_exhaustive() + } + } + + pub type $public = $crate::types::WrappedPublicBytes< + { qp_rusty_crystals_dilithium::$module::PUBLICKEYBYTES }, + $tag, + >; + pub type $signature = $crate::types::WrappedSignatureBytes< + { qp_rusty_crystals_dilithium::$module::SIGNBYTES }, + $tag, + >; + + /// Combined signature and public key structure. + /// + /// Required because the runtime address is a hash of the public key, so the + /// signature must carry the public key along. Byte layout: + /// `[signature_bytes][public_key_bytes]`. + #[derive( + Clone, + Eq, + PartialEq, + Hash, + codec::Encode, + codec::Decode, + scale_info::TypeInfo, + codec::MaxEncodedLen, + Ord, + PartialOrd, + codec::DecodeWithMemTracking, + )] + pub struct $sig_with_public { + /// Raw bytes containing both signature and public key + pub bytes: [u8; $sig_with_public::TOTAL_LEN], + } + + impl $sig_with_public { + const SIGNATURE_LEN: usize = <$signature as sp_core::ByteArray>::LEN; + const PUBLIC_LEN: usize = <$public as sp_core::ByteArray>::LEN; + pub const TOTAL_LEN: usize = Self::SIGNATURE_LEN + Self::PUBLIC_LEN; + + /// Creates a new combined signature and public key structure + pub fn new(signature: $signature, public: $public) -> Self { + let mut bytes = [0u8; ::LEN]; + bytes[..Self::SIGNATURE_LEN].copy_from_slice(signature.as_ref()); + bytes[Self::SIGNATURE_LEN..].copy_from_slice(public.as_ref()); + Self { bytes } + } + + /// Extracts the signature portion + pub fn signature(&self) -> $signature { + <$signature as sp_core::ByteArray>::from_slice(&self.bytes[..Self::SIGNATURE_LEN]) + .expect("Invalid signature") + } + + /// Extracts the public key portion + pub fn public(&self) -> $public { + <$public as sp_core::ByteArray>::from_slice(&self.bytes[Self::SIGNATURE_LEN..]) + .expect("Invalid public key") + } + + /// Returns the raw bytes + pub fn to_bytes(&self) -> [u8; Self::TOTAL_LEN] { + self.bytes + } + + /// Creates from raw bytes containing signature and public key + /// + /// # Errors + /// Returns `Error::InvalidLength` if the byte array is not the expected length + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != Self::TOTAL_LEN { + return Err($crate::types::Error::InvalidLength); + } + + let signature = <$signature as sp_core::ByteArray>::from_slice( + &bytes[..Self::SIGNATURE_LEN], + ) + .map_err(|_| $crate::types::Error::InvalidLength)?; + let public = <$public as sp_core::ByteArray>::from_slice( + &bytes[Self::SIGNATURE_LEN..], + ) + .map_err(|_| $crate::types::Error::InvalidLength)?; + + Ok(Self::new(signature, public)) + } + } + + impl alloc::fmt::Debug for $sig_with_public { + #[cfg(feature = "std")] + fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { + write!( + f, + "{} {{ signature: {:?}, public: {:?} }}", + stringify!($sig_with_public), + self.signature(), + self.public() + ) + } + + #[cfg(not(feature = "std"))] + fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { + write!(f, stringify!($sig_with_public)) + } + } + + impl From<$sig_with_public> for $crate::types::DilithiumSignatureScheme { + fn from(x: $sig_with_public) -> Self { + Self::$variant(x) + } + } + + impl TryFrom<$crate::types::DilithiumSignatureScheme> for $sig_with_public { + type Error = (); + fn try_from(m: $crate::types::DilithiumSignatureScheme) -> Result { + match m { + $crate::types::DilithiumSignatureScheme::$variant(sig_with_public) => + Ok(sig_with_public), + _ => Err(()), + } + } + } + + impl AsMut<[u8]> for $sig_with_public { + fn as_mut(&mut self) -> &mut [u8] { + self.bytes.as_mut() + } + } + + impl TryFrom<&[u8]> for $sig_with_public { + type Error = (); + fn try_from(data: &[u8]) -> Result { + if data.len() != Self::TOTAL_LEN { + return Err(()); + } + let (sig_bytes, pub_bytes) = data.split_at(Self::SIGNATURE_LEN); + let signature = + <$signature as sp_core::ByteArray>::from_slice(sig_bytes).map_err(|_| ())?; + let public = <$public as sp_core::ByteArray>::from_slice(pub_bytes).map_err(|_| ())?; + Ok(Self::new(signature, public)) + } + } + + impl sp_core::ByteArray for $sig_with_public { + const LEN: usize = Self::TOTAL_LEN; + + fn to_raw_vec(&self) -> alloc::vec::Vec { + self.to_bytes().to_vec() + } + + fn from_slice(data: &[u8]) -> Result { + if data.len() != Self::LEN { + return Err(()); + } + let bytes = <[u8; Self::LEN]>::try_from(data).map_err(|_| ())?; + Self::from_bytes(&bytes).map_err(|_| ()) + } + + fn as_slice(&self) -> &[u8] { + self.bytes.as_slice() + } + } + + impl AsRef<[u8; ::LEN]> for $sig_with_public { + fn as_ref(&self) -> &[u8; ::LEN] { + &self.bytes + } + } + + impl AsRef<[u8]> for $sig_with_public { + fn as_ref(&self) -> &[u8] { + &self.bytes + } + } + + impl sp_core::crypto::Signature for $sig_with_public {} + + impl sp_runtime::CryptoType for $sig_with_public { + type Pair = $pair; + } + + impl sp_runtime::CryptoType for $crate::types::WrappedPublicBytes { + type Pair = $pair; + } + + impl sp_runtime::CryptoType for $crate::types::WrappedSignatureBytes { + type Pair = $pair; + } + + impl sp_runtime::traits::IdentifyAccount for $public { + type AccountId = sp_runtime::AccountId32; + fn into_account(self) -> Self::AccountId { + // Use injective encoding for account ID derivation (collision-resistant for security) + let public_bytes: &[u8] = self.as_ref(); + sp_runtime::AccountId32::new(qp_poseidon_core::hash_bytes(public_bytes)) + } + } + + impl From<$public> for sp_runtime::AccountId32 { + fn from(public: $public) -> Self { + sp_runtime::traits::IdentifyAccount::into_account(public) + } + } + + impl From<$public> for $crate::types::DilithiumSigner { + fn from(x: $public) -> Self { + Self::$variant(x) + } + } + + impl sp_runtime::CryptoType for $pair { + type Pair = Self; + } + + impl sp_runtime::traits::IdentifyAccount for $pair { + type AccountId = sp_runtime::AccountId32; + fn into_account(self) -> sp_runtime::AccountId32 { + sp_runtime::AccountId32::new(qp_poseidon_core::hash_bytes(&self.public)) + } + } + + impl $pair { + pub fn from_seed(seed: &[u8]) -> Result { + if seed.len() < qp_rusty_crystals_dilithium::params::SEEDBYTES { + return Err($crate::types::Error::InsufficientEntropy { + required: qp_rusty_crystals_dilithium::params::SEEDBYTES, + actual: seed.len(), + }); + } + let mut entropy_array = [0u8; 32]; + entropy_array.copy_from_slice(&seed[..32]); + let mut sensitive_entropy = + qp_rusty_crystals_dilithium::SensitiveBytes32::from(&mut entropy_array); + let keypair = qp_rusty_crystals_dilithium::$module::Keypair::generate( + &mut sensitive_entropy, + ); + Ok(Self { + secret: *keypair.secret().to_bytes(), + public: keypair.public().to_bytes(), + }) + } + + pub fn from_keypair(keypair: qp_rusty_crystals_dilithium::$module::Keypair) -> Self { + Self { + secret: *keypair.secret().to_bytes(), + public: keypair.public().to_bytes(), + } + } + + /// Create a pair from raw public and secret key bytes. + /// Use when reconstructing a pair from stored/serialized key material (e.g. wallet + /// restore). + /// + /// Rejects mismatched or corrupted key pairs: `Keypair::from_parts` re-derives the + /// public key from the secret and requires an exact match, so a pair that would + /// cause non-obvious signature verification failures downstream is unrepresentable. + pub fn from_raw(public: &[u8], secret: &[u8]) -> Result { + let secret = qp_rusty_crystals_dilithium::$module::SecretKey::from_bytes(secret) + .map_err(|_| $crate::types::Error::InvalidSecretKey)?; + let public = qp_rusty_crystals_dilithium::$module::PublicKey::from_bytes(public) + .map_err(|_| $crate::types::Error::InvalidPublicKey)?; + let keypair = qp_rusty_crystals_dilithium::$module::Keypair::from_parts(secret, public) + .map_err(|_| $crate::types::Error::InvalidPublicKey)?; + Ok(Self { + secret: *keypair.secret().to_bytes(), + public: keypair.public().to_bytes(), + }) + } + + pub fn secret_bytes(&self) -> &[u8] { + &self.secret + } + + pub fn public_bytes(&self) -> &[u8] { + &self.public + } + } + + impl sp_core::Pair for $pair { + type Public = $public; + type Seed = [u8; 32]; + type Signature = $sig_with_public; + type ProofOfPossession = $sig_with_public; + + // Dilithium doesn't support path-based derivation - use hdwallet in + // qp-rusty-crystals instead. An empty path is a valid no-op returning self unchanged. + fn derive>( + &self, + path_iter: Iter, + seed: Option, + ) -> Result<(Self, Option), sp_core::crypto::DeriveError> { + let mut path_iter = path_iter.peekable(); + if path_iter.peek().is_none() { + return Ok((self.clone(), seed)); + } + + log::error!( + "Pair::derive() is not supported for Dilithium. \ + Use qp_rusty_crystals_hdwallet::derive_key_from_mnemonic() for HD key derivation." + ); + // SoftKeyInPath is the only error available for this trait definition. + Err(sp_core::crypto::DeriveError::SoftKeyInPath) + } + + fn from_seed_slice(seed: &[u8]) -> Result { + Self::from_seed(seed).map_err(|_| sp_core::crypto::SecretStringError::InvalidSeed) + } + + #[cfg(feature = "full_crypto")] + fn sign(&self, message: &[u8]) -> $sig_with_public { + let secret = qp_rusty_crystals_dilithium::$module::SecretKey::from_bytes( + &self.secret, + ) + .expect("Failed to parse secret key"); + + let signature = secret.sign(message, None, None).expect("Signing should not fail"); + let signature_bytes: &[u8] = signature.as_ref(); + let signature = <$signature as TryFrom<&[u8]>>::try_from(signature_bytes) + .expect("Wrap doesn't fail"); + + $sig_with_public::new(signature, ::public(self)) + } + + fn verify>( + sig: &$sig_with_public, + message: M, + pubkey: &$public, + ) -> bool { + let sig_scheme = $crate::types::DilithiumSignatureScheme::$variant(sig.clone()); + let signer = $crate::types::DilithiumSigner::$variant(pubkey.clone()); + let account = sp_runtime::traits::IdentifyAccount::into_account(signer); + sp_runtime::traits::Verify::verify(&sig_scheme, message.as_ref(), &account) + } + + fn public(&self) -> Self::Public { + <$public as sp_core::ByteArray>::from_slice(&self.public) + .expect("Valid public key bytes") + } + + fn to_raw_vec(&self) -> alloc::vec::Vec { + // this is modeled after sr25519 which returns the private key for this method + self.secret.to_vec() + } + + // NOTE: This method does not parse all secret uris correctly, like + // "mnemonic///password///account" This was supported in standard substrate, if + // there is demand, we can support it in the future + fn from_string( + s: &str, + password_override: Option<&str>, + ) -> Result { + let res = ::from_phrase(s, password_override) + .map_err(|_| sp_core::crypto::SecretStringError::InvalidPhrase)?; + Ok(res.0) + } + + #[cfg(feature = "std")] + fn from_phrase( + phrase: &str, + password: Option<&str>, + ) -> Result<(Self, Self::Seed), sp_core::crypto::SecretStringError> { + // Default derivation path for Quantus: m/44'/189189'/0'/0'/0' + const DEFAULT_PATH: &str = "m/44'/189189'/0'/0'/0'"; + let mut seed_bytes = qp_rusty_crystals_hdwallet::SensitiveBytes64::zeroed(); + qp_rusty_crystals_hdwallet::mnemonic_to_seed( + phrase.to_string(), + password, + &mut seed_bytes, + ) + .map_err(|_| sp_core::crypto::SecretStringError::InvalidPhrase)?; + // The returned seed must be the actual entropy of the returned pair, so + // that `from_seed(seed)` reconstructs the very same account (users back + // up the displayed seed as recovery material). That entropy is the + // 32-byte secret derived at the default HD path: + // `derive_key_from_mnemonic` internally runs `Keypair::generate` on + // exactly these bytes, which is also what `from_seed` does. + let mut xpriv = qp_rusty_crystals_hdwallet::hderive::ExtendedPrivKey::zeroed(); + qp_rusty_crystals_hdwallet::hderive::ExtendedPrivKey::derive( + seed_bytes.as_bytes(), + DEFAULT_PATH, + &mut xpriv, + ) + .map_err(|_| sp_core::crypto::SecretStringError::InvalidPath)?; + let mut seed = [0u8; 32]; + seed.copy_from_slice(xpriv.secret().as_bytes()); + let pair = Self::from_seed(&seed) + .map_err(|_| sp_core::crypto::SecretStringError::InvalidSeed)?; + Ok((pair, seed)) + } + + #[cfg(feature = "std")] + fn from_string_with_seed( + s: &str, + password: Option<&str>, + ) -> Result<(Self, Option), sp_core::crypto::SecretStringError> { + let (pair, seed) = ::from_phrase(s, password)?; + Ok((pair, Some(seed))) + } + } + + #[cfg(feature = "std")] + impl $public { + /// Attempt to parse a Dilithium public key from a string and return it with the + /// associated SS58 address format (version). + /// + /// We primarily support hex-encoded (0x-prefixed) public keys here; SS58 + /// AccountId32 addresses are not convertible back into Dilithium public keys (the + /// CLI falls back to parsing AccountId32 when this returns an error). + pub fn from_string_with_version( + s: &str, + ) -> Result<(Self, sp_core::crypto::Ss58AddressFormat), sp_core::crypto::PublicError> + { + use sp_core::crypto::{default_ss58_version, PublicError}; + // Accept 0x-prefixed hex of the raw Dilithium public key bytes. + let maybe_hex = s.strip_prefix("0x").unwrap_or(s); + // Expect exact hex length for a Dilithium public key + let expected_hex_len = ::LEN * 2; + if maybe_hex.len() == expected_hex_len && + maybe_hex.chars().all(|c| c.is_ascii_hexdigit()) + { + let mut bytes = alloc::vec![0u8; ::LEN]; + for (i, chunk) in maybe_hex.as_bytes().chunks(2).enumerate() { + let h = (chunk[0] as char) + .to_digit(16) + .ok_or(PublicError::InvalidFormat)? as u8; + let l = (chunk[1] as char) + .to_digit(16) + .ok_or(PublicError::InvalidFormat)? as u8; + bytes[i] = (h << 4) | l; + } + let pk = ::from_slice(&bytes) + .map_err(|_| PublicError::BadLength)?; + return Ok((pk, default_ss58_version())); + } + // Not a supported Dilithium public key representation here. + Err(PublicError::InvalidFormat) + } + } + + /// Verifies a Dilithium signature for this parameter set. + /// + /// Returns `true` if the signature is valid for `msg` under `pub_key`, + /// `false` otherwise (including malformed public keys). + pub fn $verify_fn(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool { + match qp_rusty_crystals_dilithium::$module::PublicKey::from_bytes(pub_key) { + Ok(pk) => pk.verify(msg, sig, None), + Err(e) => { + log::warn!("public key failed to deserialize {:?}", e); + false + }, + } + } + }; +} + +pub(crate) use define_dilithium_scheme; diff --git a/primitives/dilithium-crypto/src/traits.rs b/primitives/dilithium-crypto/src/traits.rs index 329f47320..4ef79aa3f 100644 --- a/primitives/dilithium-crypto/src/traits.rs +++ b/primitives/dilithium-crypto/src/traits.rs @@ -1,9 +1,7 @@ use super::types::{ - DilithiumPair, DilithiumPublic, DilithiumSignatureScheme, DilithiumSigner, Error, - WrappedPublicBytes, WrappedSignatureBytes, + DilithiumSignatureScheme, DilithiumSigner, WrappedPublicBytes, WrappedSignatureBytes, }; -use crate::{DilithiumSignature, DilithiumSignatureWithPublic}; use alloc::vec::Vec; use qp_poseidon_core::hash_bytes; use sp_core::{ @@ -12,43 +10,9 @@ use sp_core::{ }; use sp_runtime::{ traits::{IdentifyAccount, Verify}, - AccountId32, CryptoType, + AccountId32, }; -/// Verifies a Dilithium ML-DSA-87 signature -/// -/// This function performs signature verification using the Dilithium post-quantum -/// cryptographic signature scheme (ML-DSA-87). It validates that the given signature -/// was created by the holder of the private key corresponding to the public key. -/// -/// # Arguments -/// * `pub_key` - The public key bytes (must be valid Dilithium public key) -/// * `msg` - The message that was signed -/// * `sig` - The signature bytes to verify -/// -/// # Returns -/// `true` if the signature is valid and verification succeeds, `false` otherwise -/// -/// # Examples -/// ```ignore -/// use qp_dilithium_crypto::verify; -/// -/// let valid = verify(&public_key_bytes, &message, &signature_bytes); -/// if valid { -/// println!("Signature is valid!"); -/// } -/// ``` -pub fn verify(pub_key: &[u8], msg: &[u8], sig: &[u8]) -> bool { - use qp_rusty_crystals_dilithium::ml_dsa_87::PublicKey; - match PublicKey::from_bytes(pub_key) { - Ok(pk) => pk.verify(msg, sig, None), - Err(e) => { - log::warn!("public key failed to deserialize {:?}", e); - false - }, - } -} - // // Trait implementations for WrappedPublicBytes // @@ -86,10 +50,10 @@ impl ByteArray for WrappedPublicBytes { self.0.as_slice().to_vec() } } -impl CryptoType for WrappedPublicBytes { - type Pair = DilithiumPair; +impl Public for WrappedPublicBytes where + Self: sp_runtime::CryptoType +{ } -impl Public for WrappedPublicBytes {} impl Default for WrappedPublicBytes { fn default() -> Self { @@ -110,14 +74,6 @@ impl alloc::fmt::Debug for WrappedPublicBytes } } -impl IdentifyAccount for DilithiumPublic { - type AccountId = AccountId32; - fn into_account(self) -> Self::AccountId { - // Use injective encoding for account ID derivation (collision-resistant for security) - AccountId32::new(hash_bytes(self.0.as_slice())) - } -} - pub struct WormholeAddress(pub H256); // AccountID32 for a wormhole address is the same as the address itself @@ -164,10 +120,10 @@ impl ByteArray for WrappedSignatureBytes { self.0.as_slice().to_vec() } } -impl CryptoType for WrappedSignatureBytes { - type Pair = DilithiumPair; +impl Signature for WrappedSignatureBytes where + Self: sp_runtime::CryptoType +{ } -impl Signature for WrappedSignatureBytes {} impl Default for WrappedSignatureBytes { fn default() -> Self { @@ -189,14 +145,6 @@ impl alloc::fmt::Debug for WrappedSignatureBytes::AccountId, ) -> bool { - let Self::Dilithium(sig_public) = self; - let account = sig_public.public().clone().into_account(); - if account != *signer { - return false; + match self { + Self::Dilithium87(sig_public) => { + let account = sig_public.public().clone().into_account(); + if account != *signer { + return false; + } + crate::verify_ml_dsa_87( + sig_public.public().as_ref(), + msg.get(), + sig_public.signature().as_ref(), + ) + }, + Self::Dilithium65(sig_public) => { + let account = sig_public.public().clone().into_account(); + if account != *signer { + return false; + } + crate::verify_ml_dsa_65( + sig_public.public().as_ref(), + msg.get(), + sig_public.signature().as_ref(), + ) + }, } - verify(sig_public.public().as_ref(), msg.get(), sig_public.signature().as_ref()) } } // // Trait implementations for DilithiumSigner // -impl From for DilithiumSigner { - fn from(x: DilithiumPublic) -> Self { - Self::Dilithium(x) - } -} impl IdentifyAccount for DilithiumSigner { type AccountId = AccountId32; fn into_account(self) -> AccountId32 { // Use injective encoding for account ID derivation (collision-resistant for security) - let Self::Dilithium(who) = self; - hash_bytes(who.as_ref()).into() - } -} - -impl From for AccountId32 { - fn from(public: DilithiumPublic) -> Self { - public.into_account() - } -} - -// -// Implementation methods for DilithiumPair -// - -impl DilithiumPair { - pub fn from_seed(seed: &[u8]) -> Result { - let keypair = crate::pair::generate(seed)?; - Ok(DilithiumPair { secret: keypair.secret.to_bytes(), public: keypair.public.to_bytes() }) - } - - pub fn from_keypair(keypair: qp_rusty_crystals_dilithium::ml_dsa_87::Keypair) -> Self { - DilithiumPair { secret: keypair.secret.to_bytes(), public: keypair.public.to_bytes() } - } - - /// Create DilithiumPair from raw public and secret key bytes. - /// Use when reconstructing a pair from stored/serialized key material (e.g. wallet restore). - /// - /// Verifies that the public key corresponds to the secret by signing a test message and - /// verifying it. Rejects mismatched or corrupted key pairs that would otherwise cause - /// non-obvious signature verification failures downstream. - pub fn from_raw(public: &[u8], secret: &[u8]) -> Result { - let keypair = crate::pair::create_keypair(public, secret)?; - // Verify public corresponds to secret (create_keypair only deserializes, does not validate) - const VALIDATION_MSG: &[u8] = b"qp_dilithium_crypto::from_raw_validation"; - let sig = keypair.sign(VALIDATION_MSG, None, None).map_err(|_| Error::InvalidSecretKey)?; - if !keypair.verify(VALIDATION_MSG, sig.as_ref(), None) { - return Err(Error::InvalidPublicKey); - } - Ok(DilithiumPair { secret: keypair.secret.to_bytes(), public: keypair.public.to_bytes() }) - } - - pub fn secret_bytes(&self) -> &[u8] { - &self.secret - } - - pub fn public_bytes(&self) -> &[u8] { - &self.public - } -} - -impl alloc::fmt::Debug for DilithiumSignatureWithPublic { - #[cfg(feature = "std")] - fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { - write!( - f, - "DilithiumSignatureWithPublic {{ signature: {:?}, public: {:?} }}", - self.signature(), - self.public() - ) - } - - #[cfg(not(feature = "std"))] - fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { - write!(f, "DilithiumSignatureWithPublic") - } -} - -impl From for DilithiumSignatureScheme { - fn from(x: DilithiumSignatureWithPublic) -> Self { - Self::Dilithium(x) - } -} - -impl TryFrom for DilithiumSignatureWithPublic { - type Error = (); - fn try_from(m: DilithiumSignatureScheme) -> Result { - let DilithiumSignatureScheme::Dilithium(sig_with_public) = m; - Ok(sig_with_public) - } -} - -impl AsMut<[u8]> for DilithiumSignatureWithPublic { - fn as_mut(&mut self) -> &mut [u8] { - self.bytes.as_mut() - } -} -impl TryFrom<&[u8]> for DilithiumSignatureWithPublic { - type Error = (); - fn try_from(data: &[u8]) -> Result { - if data.len() != Self::TOTAL_LEN { - return Err(()); + match self { + Self::Dilithium87(who) => hash_bytes(who.as_ref()).into(), + Self::Dilithium65(who) => hash_bytes(who.as_ref()).into(), } - let (sig_bytes, pub_bytes) = data.split_at(::LEN); - let signature = DilithiumSignature::from_slice(sig_bytes).map_err(|_| ())?; - let public = DilithiumPublic::from_slice(pub_bytes).map_err(|_| ())?; - Ok(Self::new(signature, public)) } } - -impl ByteArray for DilithiumSignatureWithPublic { - const LEN: usize = Self::TOTAL_LEN; - - fn to_raw_vec(&self) -> Vec { - self.to_bytes().to_vec() - } - - fn from_slice(data: &[u8]) -> Result { - if data.len() != Self::LEN { - return Err(()); - } - let bytes = <[u8; Self::LEN]>::try_from(data).map_err(|_| ())?; - Self::from_bytes(&bytes).map_err(|_| ()) - } - - fn as_slice(&self) -> &[u8] { - self.bytes.as_slice() - } -} -impl AsRef<[u8; Self::LEN]> for DilithiumSignatureWithPublic { - fn as_ref(&self) -> &[u8; Self::LEN] { - &self.bytes - } -} - -impl AsRef<[u8]> for DilithiumSignatureWithPublic { - fn as_ref(&self) -> &[u8] { - &self.bytes - } -} -impl Signature for DilithiumSignatureWithPublic {} - -impl CryptoType for DilithiumSignatureWithPublic { - type Pair = DilithiumPair; -} diff --git a/primitives/dilithium-crypto/src/types.rs b/primitives/dilithium-crypto/src/types.rs index c08f9fe36..be88217ed 100644 --- a/primitives/dilithium-crypto/src/types.rs +++ b/primitives/dilithium-crypto/src/types.rs @@ -1,54 +1,20 @@ +use crate::scheme_macro::define_dilithium_scheme; use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; -use qp_rusty_crystals_dilithium::ml_dsa_87::{PUBLICKEYBYTES, SECRETKEYBYTES}; use scale_info::TypeInfo; use sp_core::{ crypto::{PublicBytes, SignatureBytes}, - ByteArray, RuntimeDebug, + RuntimeDebug, }; -use zeroize::{Zeroize, ZeroizeOnDrop}; /// Resonance Crypto Types /// -/// Currently implementing the Dilithum cryprographic scheme for post quantum security +/// Implementing the Dilithium cryptographic schemes for post quantum security: +/// ML-DSA-87 and ML-DSA-65. /// -/// It is modeled after the Substrate MultiSignature and Signature types such as sr25519. +/// Modeled after the Substrate MultiSignature and Signature types such as sr25519. /// -/// For traits implemented see traits.rs - -#[derive( - Clone, - Eq, - PartialEq, - Debug, - Hash, - Encode, - Decode, - TypeInfo, - Ord, - PartialOrd, - DecodeWithMemTracking, -)] -pub struct DilithiumCryptoTag; - -/// Dilithium cryptographic key pair -/// -/// Contains both secret and public key material for Dilithium ML-DSA-87 operations. -/// -/// The secret key material is zeroized when an instance (including any clone) is -/// dropped, so released copies do not leave private-key bytes behind in memory. -#[derive(Clone, Eq, PartialEq, Zeroize, ZeroizeOnDrop)] -pub struct DilithiumPair { - pub(crate) secret: [u8; SECRETKEYBYTES], - pub(crate) public: [u8; PUBLICKEYBYTES], -} - -impl alloc::fmt::Debug for DilithiumPair { - fn fmt(&self, f: &mut alloc::fmt::Formatter) -> alloc::fmt::Result { - f.debug_struct("DilithiumPair") - .field("public", &self.public) - .finish_non_exhaustive() - } -} +/// For trait implementations shared by all schemes see traits.rs; per-scheme +/// implementations are generated by `define_dilithium_scheme!`. /// Wrapper around Substrate's PublicBytes to provide Dilithium-specific implementations /// @@ -88,13 +54,11 @@ pub struct WrappedPublicBytes(pub PublicBytes )] pub struct WrappedSignatureBytes(pub SignatureBytes); -pub type DilithiumPublic = WrappedPublicBytes<{ crate::PUB_KEY_BYTES }, DilithiumCryptoTag>; -pub type DilithiumSignature = WrappedSignatureBytes<{ crate::SIGNATURE_BYTES }, DilithiumCryptoTag>; - /// Dilithium signature scheme - drop-in replacement for MultiSignature /// -/// Currently supports only Dilithium, but structured as an enum to allow -/// for future signature schemes to be added easily. +/// Supports ML-DSA-87 (`Dilithium87`) and ML-DSA-65 (`Dilithium65`). New variants +/// must only be appended: existing variant indices are part of the extrinsic +/// wire format. #[derive( Eq, PartialEq, @@ -107,12 +71,13 @@ pub type DilithiumSignature = WrappedSignatureBytes<{ crate::SIGNATURE_BYTES }, DecodeWithMemTracking, )] pub enum DilithiumSignatureScheme { - Dilithium(DilithiumSignatureWithPublic), + Dilithium87(Dilithium87SignatureWithPublic), + Dilithium65(Dilithium65SignatureWithPublic), } /// Dilithium signer - replacement for MultiSigner /// -/// Identifies the signer of a transaction using Dilithium public key +/// Identifies the signer of a transaction using a Dilithium public key #[derive( Eq, PartialEq, @@ -126,7 +91,8 @@ pub enum DilithiumSignatureScheme { DecodeWithMemTracking, )] pub enum DilithiumSigner { - Dilithium(DilithiumPublic), + Dilithium87(Dilithium87Public), + Dilithium65(Dilithium65Public), } #[derive(Debug)] @@ -147,94 +113,24 @@ pub enum Error { InvalidPublicKey, } -/// Combined signature and public key structure for Dilithium -/// -/// This structure contains both the signature and the public key in a single -/// byte array, which is required for certain Substrate operations. The layout -/// is: [signature_bytes][public_key_bytes]. -#[derive( - Clone, - Eq, - PartialEq, - Hash, - Encode, - Decode, - TypeInfo, - MaxEncodedLen, - Ord, - PartialOrd, - DecodeWithMemTracking, -)] -pub struct DilithiumSignatureWithPublic { - /// Raw bytes containing both signature and public key - pub bytes: [u8; DilithiumSignatureWithPublic::TOTAL_LEN], +define_dilithium_scheme! { + variant = Dilithium87, + tag = Dilithium87CryptoTag, + pair = Dilithium87Pair, + public = Dilithium87Public, + signature = Dilithium87Signature, + sig_with_public = Dilithium87SignatureWithPublic, + module = ml_dsa_87, + verify_fn = verify_ml_dsa_87, } -impl DilithiumSignatureWithPublic { - const SIGNATURE_LEN: usize = ::LEN; - const PUBLIC_LEN: usize = ::LEN; - pub const TOTAL_LEN: usize = Self::SIGNATURE_LEN + Self::PUBLIC_LEN; - - /// Creates a new combined signature and public key structure - /// - /// # Arguments - /// * `signature` - The Dilithium signature - /// * `public` - The Dilithium public key - /// - /// # Returns - /// A new `DilithiumSignatureWithPublic` instance - pub fn new(signature: DilithiumSignature, public: DilithiumPublic) -> Self { - let mut bytes = [0u8; Self::LEN]; - bytes[..Self::SIGNATURE_LEN].copy_from_slice(signature.as_ref()); - bytes[Self::SIGNATURE_LEN..].copy_from_slice(public.as_ref()); - Self { bytes } - } - - /// Extracts the signature portion - /// - /// # Returns - /// The `DilithiumSignature` contained in this structure - pub fn signature(&self) -> DilithiumSignature { - DilithiumSignature::from_slice(&self.bytes[..Self::SIGNATURE_LEN]) - .expect("Invalid signature") - } - - /// Extracts the public key portion - /// - /// # Returns - /// The `DilithiumPublic` key contained in this structure - pub fn public(&self) -> DilithiumPublic { - DilithiumPublic::from_slice(&self.bytes[Self::SIGNATURE_LEN..]).expect("Invalid public key") - } - - /// Returns the raw bytes - /// - /// # Returns - /// A copy of the internal byte array - pub fn to_bytes(&self) -> [u8; Self::TOTAL_LEN] { - self.bytes - } - - /// Creates a `DilithiumSignatureWithPublic` from raw bytes - /// - /// # Arguments - /// * `bytes` - Raw bytes containing signature and public key - /// - /// # Returns - /// `Ok(DilithiumSignatureWithPublic)` on success, `Err(Error)` if the bytes are invalid - /// - /// # Errors - /// Returns `Error::InvalidLength` if the byte array is not the expected length - pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() != Self::TOTAL_LEN { - return Err(Error::InvalidLength); - } - - let signature = DilithiumSignature::from_slice(&bytes[..Self::SIGNATURE_LEN]) - .map_err(|_| Error::InvalidLength)?; - let public = DilithiumPublic::from_slice(&bytes[Self::SIGNATURE_LEN..]) - .map_err(|_| Error::InvalidLength)?; - - Ok(Self::new(signature, public)) - } +define_dilithium_scheme! { + variant = Dilithium65, + tag = Dilithium65CryptoTag, + pair = Dilithium65Pair, + public = Dilithium65Public, + signature = Dilithium65Signature, + sig_with_public = Dilithium65SignatureWithPublic, + module = ml_dsa_65, + verify_fn = verify_ml_dsa_65, } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index e5fb1c2ab..fca467f5e 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -9,7 +9,10 @@ pub mod apis; mod benchmarks; pub mod configs; -pub use qp_dilithium_crypto::{DilithiumPublic, DilithiumSignature, DilithiumSignatureScheme}; +pub use qp_dilithium_crypto::{ + Dilithium65Pair, Dilithium65Public, Dilithium65Signature, Dilithium65SignatureWithPublic, + Dilithium87Public, Dilithium87Signature, DilithiumSignatureScheme, +}; use alloc::vec::Vec; use sp_core::U512; diff --git a/runtime/tests/transactions/integration.rs b/runtime/tests/transactions/integration.rs index 53e46234b..81b723402 100644 --- a/runtime/tests/transactions/integration.rs +++ b/runtime/tests/transactions/integration.rs @@ -1,6 +1,8 @@ use codec::{Decode, Encode}; -use qp_dilithium_crypto::{DilithiumSignatureScheme, DilithiumSignatureWithPublic, PUB_KEY_BYTES}; -use sp_core::ByteArray; +use qp_dilithium_crypto::{ + Dilithium65Pair, Dilithium87SignatureWithPublic, DilithiumSignatureScheme, PUB_KEY_BYTES, +}; +use sp_core::{ByteArray, Pair}; use sp_runtime::{ generic::{Preamble, UncheckedExtrinsic}, traits::Verify, @@ -24,7 +26,7 @@ pub fn format_hex_truncated(bytes: &[u8]) -> String { #[cfg(test)] mod tests { - use qp_dilithium_crypto::{DilithiumPublic, DilithiumSignature}; + use qp_dilithium_crypto::{Dilithium87Public, Dilithium87Signature}; use qp_poseidon_core::hash_bytes; use super::*; @@ -46,7 +48,7 @@ mod tests { // Generate a keypair let entropy = [0u8; 32]; // Fixed entropy of all zeros let keypair = qp_dilithium_crypto::generate(&entropy).expect("Failed to generate keypair"); - let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public.to_bytes(); + let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public().to_bytes(); println!("Gen Public Key (hex): {:?}", format_hex_truncated(&pk_bytes)); @@ -58,7 +60,7 @@ mod tests { println!("Gen Signature (hex): {:?}", format_hex_truncated(&sig_bytes)); let signature = - DilithiumSignature::from_slice(&sig_bytes).expect("Signature length mismatch"); + Dilithium87Signature::from_slice(&sig_bytes).expect("Signature length mismatch"); let bytes: &[u8] = signature.as_ref(); // or signature.as_slice() println!("Gen Signature bytes: {:?}", format_hex_truncated(bytes)); @@ -71,15 +73,15 @@ mod tests { println!("Payload AccountId: {:?}", &id); let signed_extra: SignedExtra = (); - let sig_with_public = DilithiumSignatureWithPublic::new( + let sig_with_public = Dilithium87SignatureWithPublic::new( signature, - DilithiumPublic::from_slice(&pk_bytes).unwrap(), + Dilithium87Public::from_slice(&pk_bytes).unwrap(), ); let extrinsic = UncheckedExtrinsic::new_signed( payload, id, - DilithiumSignatureScheme::Dilithium(sig_with_public), + DilithiumSignatureScheme::Dilithium87(sig_with_public), signed_extra, ); @@ -111,7 +113,10 @@ mod tests { println!("Decoded Address: {:?}", decoded_address); println!("Decoded Extra: ()"); - let DilithiumSignatureScheme::Dilithium(sig_public) = decoded_signature.clone(); + let DilithiumSignatureScheme::Dilithium87(sig_public) = decoded_signature.clone() + else { + panic!("Expected Dilithium87 (ML-DSA-87) signature variant") + }; let sig = sig_public.signature(); let sig_bytes = sig.as_slice(); println!("Decoded Signature: {:?}", format_hex_truncated(sig_bytes)); @@ -150,7 +155,7 @@ mod tests { // Generate a keypair let entropy = [0u8; 32]; // Fixed entropy of all zeros let keypair = qp_dilithium_crypto::generate(&entropy).expect("Failed to generate keypair"); - let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public.to_bytes(); + let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public().to_bytes(); // Use injective encoding for account ID derivation (collision-resistant) let account_id = hash_bytes(&pk_bytes).into(); let id = Address::Id(account_id); @@ -165,19 +170,19 @@ mod tests { let keypair2 = qp_dilithium_crypto::generate(&entropy2).expect("Failed to generate keypair"); let sig_bytes_wrong_key = keypair2.sign(&msg, None, None).expect("Failed to sign message"); - let signature_wrong_key = DilithiumSignature::try_from(&sig_bytes_wrong_key[..]) + let signature_wrong_key = Dilithium87Signature::try_from(&sig_bytes_wrong_key[..]) .expect("Signature length mismatch"); - let sig_with_public = DilithiumSignatureWithPublic::new( + let sig_with_public = Dilithium87SignatureWithPublic::new( signature_wrong_key, - DilithiumPublic::from_slice(&pk_bytes).unwrap(), + Dilithium87Public::from_slice(&pk_bytes).unwrap(), ); // Create transaction with invalid signature let extrinsic = UncheckedExtrinsic::new_signed( payload, id, - DilithiumSignatureScheme::Dilithium(sig_with_public), + DilithiumSignatureScheme::Dilithium87(sig_with_public), signed_extra, ); @@ -212,14 +217,14 @@ mod tests { // Generate a keypair let entropy = [0u8; 32]; // Fixed entropy of all zeros let keypair = qp_dilithium_crypto::generate(&entropy).expect("Failed to generate keypair"); - let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public.to_bytes(); + let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public().to_bytes(); // Create and sign a payload let payload: RuntimeCall = 77; let msg = payload.encode(); let sig_bytes = keypair.sign(&msg, None, None).expect("Failed to sign message"); let signature = - DilithiumSignature::try_from(&sig_bytes[..]).expect("Signature length mismatch"); + Dilithium87Signature::try_from(&sig_bytes[..]).expect("Signature length mismatch"); // Create a second account // Use injective encoding for account ID derivation (collision-resistant) @@ -227,16 +232,16 @@ mod tests { let id_2 = Address::Id(account_id_2); let signed_extra: SignedExtra = (); - let sig_with_public = DilithiumSignatureWithPublic::new( + let sig_with_public = Dilithium87SignatureWithPublic::new( signature, - DilithiumPublic::from_slice(&pk_bytes).unwrap(), + Dilithium87Public::from_slice(&pk_bytes).unwrap(), ); // Create transaction with wrong account ID. let extrinsic = UncheckedExtrinsic::new_signed( payload, id_2, - DilithiumSignatureScheme::Dilithium(sig_with_public), // correct signature! + DilithiumSignatureScheme::Dilithium87(sig_with_public), // correct signature! signed_extra, ); @@ -273,14 +278,14 @@ mod tests { // Generate a keypair let entropy = [0u8; 32]; // Fixed entropy of all zeros let keypair = qp_dilithium_crypto::generate(&entropy).expect("Failed to generate keypair"); - let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public.to_bytes(); + let pk_bytes: [u8; PUB_KEY_BYTES] = keypair.public().to_bytes(); // Create and sign a payload let payload: RuntimeCall = 42; let msg = payload.encode(); let sig_bytes = keypair.sign(&msg, None, None).expect("Failed to sign message"); let signature = - DilithiumSignature::from_slice(&sig_bytes).expect("Signature length mismatch"); + Dilithium87Signature::from_slice(&sig_bytes).expect("Signature length mismatch"); // Use injective encoding for account ID derivation (collision-resistant) let account_id = hash_bytes(&pk_bytes).into(); @@ -290,15 +295,15 @@ mod tests { // Create transaction with wrong payload. Should fail. let wrong_payload: RuntimeCall = 40; - let sig_with_public = DilithiumSignatureWithPublic::new( + let sig_with_public = Dilithium87SignatureWithPublic::new( signature, - DilithiumPublic::from_slice(&pk_bytes).unwrap(), + Dilithium87Public::from_slice(&pk_bytes).unwrap(), ); let extrinsic = UncheckedExtrinsic::new_signed( wrong_payload, id, - DilithiumSignatureScheme::Dilithium(sig_with_public), + DilithiumSignatureScheme::Dilithium87(sig_with_public), signed_extra, ); @@ -321,4 +326,105 @@ mod tests { let is_valid = signature.verify(&msg_decoded[..], &decoded_account_id); assert!(!is_valid, "Signature verification worked with wrong payload"); } + + // + // Integration test for ML-DSA-65 signatures + // Tests the valid round-trip case + // + #[test] + fn test_dilithium65_extrinsic() { + setup(); + + let pair = Dilithium65Pair::from_seed_slice(&[0u8; 32]).expect("Failed to create pair"); + let public = pair.public(); + let account_id: AccountId32 = hash_bytes(public.as_ref()).into(); + let id = Address::Id(account_id); + let signed_extra: SignedExtra = (); + + let payload: RuntimeCall = 42; + let msg = payload.encode(); + let sig_with_public = pair.sign(&msg); + + let extrinsic = UncheckedExtrinsic::new_signed( + payload, + id, + DilithiumSignatureScheme::Dilithium65(sig_with_public), + signed_extra, + ); + + let encoded = extrinsic.encode(); + + let decoded: UncheckedExtrinsic< + MultiAddress, + RuntimeCall, + DilithiumSignatureScheme, + (), + > = UncheckedExtrinsic::decode(&mut &encoded[..]).expect("Decoding failed"); + + assert_eq!(decoded.function, payload, "Decoded function does not match original payload"); + + let Preamble::Signed(address, signature, _) = decoded.preamble else { + unreachable!("Test assumes Preamble::Signed") + }; + let Address::Id(decoded_account_id) = address else { + unreachable!("Test assumes Address::Id") + }; + let msg_decoded = decoded.function.encode(); + + let is_valid = signature.verify(&msg_decoded[..], &decoded_account_id); + assert!( + is_valid, + "ML-DSA-65 signature verification failed for AccountId: {:?}", + decoded_account_id + ); + } + + #[test] + fn test_dilithium65_extrinsic_fail_signature() { + setup(); + + let pair = Dilithium65Pair::from_seed_slice(&[0u8; 32]).expect("Failed to create pair"); + let public = pair.public(); + let account_id: AccountId32 = hash_bytes(public.as_ref()).into(); + let id = Address::Id(account_id); + let signed_extra: SignedExtra = (); + + let payload: RuntimeCall = 99; + let msg = payload.encode(); + + // Sign payload with a different key + let pair2 = Dilithium65Pair::from_seed_slice(&[1u8; 32]).expect("Failed to create pair"); + let wrong_sig = pair2.sign(&msg); + // Recombine the wrong signature with the first pair's public key + let sig_with_public = qp_dilithium_crypto::Dilithium65SignatureWithPublic::new( + wrong_sig.signature(), + public.clone(), + ); + + let extrinsic = UncheckedExtrinsic::new_signed( + payload, + id, + DilithiumSignatureScheme::Dilithium65(sig_with_public), + signed_extra, + ); + + let encoded = extrinsic.encode(); + let decoded: UncheckedExtrinsic< + MultiAddress, + RuntimeCall, + DilithiumSignatureScheme, + (), + > = UncheckedExtrinsic::decode(&mut &encoded[..]).expect("Decoding failed"); + + let Preamble::Signed(address, signature, _) = decoded.preamble else { + unreachable!("Test assumes Preamble::Signed") + }; + let Address::Id(decoded_account_id) = address else { + unreachable!("Test assumes Address::Id") + }; + let msg_decoded = decoded.function.encode(); + + let is_valid = signature.verify(&msg_decoded[..], &decoded_account_id); + assert!(!is_valid, "ML-DSA-65 signature verification unexpectedly succeeded"); + } } diff --git a/runtime/tests/transactions/ml_dsa_65.rs b/runtime/tests/transactions/ml_dsa_65.rs new file mode 100644 index 000000000..9f5b85a7a --- /dev/null +++ b/runtime/tests/transactions/ml_dsa_65.rs @@ -0,0 +1,115 @@ +//! End-to-end tests: extrinsics signed with ML-DSA-65 through the full runtime +//! transaction pipeline (`Executive::apply_extrinsic` with all `TxExtension`s). + +use codec::Encode; +use frame_support::{assert_ok, traits::Currency}; +use qp_dilithium_crypto::Dilithium65Pair; +use quantus_runtime::{ + transaction_extensions::{ReversibleTransactionExtension, WormholeProofRecorderExtension}, + Balances, BalancesCall, Executive, Runtime, RuntimeCall, Signature, SignedPayload, System, + TxExtension, UncheckedExtrinsic, UNIT, VERSION, +}; +use sp_core::Pair; +use sp_runtime::{generic::Era, traits::IdentifyAccount, AccountId32, MultiAddress}; + +fn test_ext(account: &AccountId32) -> sp_io::TestExternalities { + use quantus_runtime::BuildStorage; + + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + Balances::make_free_balance_be(account, 1000 * UNIT); + System::set_block_number(1); + }); + ext +} + +/// Build a `transfer_keep_alive` extrinsic signed by `pair`, claiming `sender` +/// as the transaction origin. +fn signed_transfer( + pair: &Dilithium65Pair, + sender: AccountId32, + dest: AccountId32, + value: u128, + nonce: u32, +) -> UncheckedExtrinsic { + let genesis_hash = System::block_hash(0); + let call: RuntimeCall = + BalancesCall::transfer_keep_alive { dest: MultiAddress::Id(dest), value }.into(); + + let tx_ext: TxExtension = ( + frame_system::CheckNonZeroSender::::new(), + frame_system::CheckSpecVersion::::new(), + frame_system::CheckTxVersion::::new(), + frame_system::CheckGenesis::::new(), + frame_system::CheckEra::::from(Era::immortal()), + frame_system::CheckNonce::::from(nonce), + frame_system::CheckWeight::::new(), + pallet_transaction_payment::ChargeTransactionPayment::::from(0), + frame_metadata_hash_extension::CheckMetadataHash::::new(false), + ReversibleTransactionExtension::::new(), + WormholeProofRecorderExtension::::new(), + ); + + let raw_payload = SignedPayload::from_raw( + call.clone(), + tx_ext.clone(), + ( + (), + VERSION.spec_version, + VERSION.transaction_version, + genesis_hash, + genesis_hash, + (), + (), + (), + None, + (), + (), + ), + ); + let signature = raw_payload.using_encoded(|e| pair.sign(e)); + + UncheckedExtrinsic::new_signed( + call, + MultiAddress::Id(sender), + Signature::Dilithium65(signature), + tx_ext, + ) +} + +#[test] +fn test_ml_dsa_65_extrinsic_accepted_by_runtime() { + let pair = Dilithium65Pair::from_seed_slice(&[42u8; 32]).expect("valid seed"); + let account = pair.public().into_account(); + let mut ext = test_ext(&account); + + ext.execute_with(|| { + let dest = AccountId32::new([9u8; 32]); + let xt = signed_transfer(&pair, account.clone(), dest.clone(), 10 * UNIT, 0); + + let outcome = Executive::apply_extrinsic(xt) + .expect("ML-DSA-65 signed extrinsic should pass validation"); + assert_ok!(outcome); + assert_eq!(Balances::free_balance(&dest), 10 * UNIT); + }); +} + +#[test] +fn test_ml_dsa_65_extrinsic_wrong_signer_rejected() { + let pair = Dilithium65Pair::from_seed_slice(&[42u8; 32]).expect("valid seed"); + let account = pair.public().into_account(); + let mut ext = test_ext(&account); + + ext.execute_with(|| { + // The payload is signed by `pair`, but the extrinsic claims a different sender. + let impostor = AccountId32::new([7u8; 32]); + let dest = AccountId32::new([9u8; 32]); + let xt = signed_transfer(&pair, impostor, dest, 10 * UNIT, 0); + + assert!( + Executive::apply_extrinsic(xt).is_err(), + "extrinsic with mismatched signer must be rejected" + ); + }); +} diff --git a/runtime/tests/transactions/mod.rs b/runtime/tests/transactions/mod.rs index 63705e014..00c1d89e5 100644 --- a/runtime/tests/transactions/mod.rs +++ b/runtime/tests/transactions/mod.rs @@ -1,3 +1,4 @@ pub mod integration; +pub mod ml_dsa_65; pub mod recovery; pub mod reversible_integration;