From fc9842f582dde08de87947bf52d1f49b75d07a4b Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 10 Oct 2026 02:07:09 +0800 Subject: [PATCH] Hedge every ML-DSA signature and add --keep-alive to send and multisig transfer Deterministic ML-DSA plus a nonce reset by reaping re-signs a byte-identical extrinsic: same hash, and the first one becomes replayable once the account is re-funded. Every signature the CLI makes on the host (extrinsics and airdrop claims) now mixes in 32 bytes of OS randomness; an RNG failure is a hard error. `send` and `multisig propose transfer` keep transfer_allow_death as their default and gain an opt-in --keep-alive that proposes transfer_keep_alive. --- README.md | 6 +++++ src/chain/mod.rs | 6 +++++ src/chain/signing.rs | 49 +++++++++++++++++++++++++++++++++-------- src/cli/airdrop.rs | 2 +- src/cli/cold_signing.rs | 5 +---- src/cli/mod.rs | 8 ++++++- src/cli/multisig.rs | 44 +++++++++++++++++++++--------------- src/cli/runtime.rs | 7 +----- src/cli/send.rs | 44 +++++++++++++++++++++++++++--------- 9 files changed, 122 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 056c6ad..35e91fa 100644 --- a/README.md +++ b/README.md @@ -766,6 +766,9 @@ quantus send --from crystal_alice --to
--amount 10 --tip 0.1 # With manual nonce quantus send --from crystal_alice --to
--amount 10 --nonce 42 + +# Refuse to reap the sender: transfer_keep_alive instead of the default transfer_allow_death +quantus send --from crystal_alice --to
--amount 10 --keep-alive ``` Transaction status terms: @@ -1146,6 +1149,9 @@ quantus multisig propose transfer \ --expiry \ --from signer_wallet +# --keep-alive proposes transfer_keep_alive instead of the default transfer_allow_death +# (the transfer fails rather than reaping the multisig; not for high-security multisigs) + # Amount formats supported: # 10 → 10 QUAN # 10.5 → 10.5 QUAN diff --git a/src/chain/mod.rs b/src/chain/mod.rs index babfbcb..e7bc905 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -8,3 +8,9 @@ pub mod client; pub mod quantus_subxt; pub mod signing; + +#[cfg(test)] +pub(crate) fn test_metadata() -> subxt::Metadata { + let bytes: &[u8] = include_bytes!("../quantus_metadata.scale"); + ::decode(&mut &bytes[..]).expect("valid checked-in metadata") +} diff --git a/src/chain/signing.rs b/src/chain/signing.rs index fa18a82..f754d1e 100644 --- a/src/chain/signing.rs +++ b/src/chain/signing.rs @@ -13,6 +13,8 @@ use qp_dilithium_crypto::types::{ Dilithium65Pair, Dilithium65Signature, Dilithium65SignatureWithPublic, Dilithium87Pair, Dilithium87Signature, Dilithium87SignatureWithPublic, }; +use qp_rusty_crystals_dilithium::SensitiveBytes32; +use rand::TryRngCore; use sp_core::{ByteArray, Pair}; /// The FIPS 204 context runtimes from spec 148 bind extrinsic signatures to. Mirrors @@ -26,6 +28,17 @@ pub fn context_for_runtime(spec_version: u32, transaction_version: u32) -> Optio .then_some(EXTRINSIC) } +/// Fresh randomness for one signature. Deterministic ML-DSA plus a nonce reset by reaping would +/// re-sign a byte-identical extrinsic (same hash, the first one replayable); hedging also closes +/// the known-mask fault attacks on deterministic signing. +pub(crate) fn hedge() -> SensitiveBytes32 { + let mut hedge = SensitiveBytes32::zeroed(); + rand::rngs::OsRng + .try_fill_bytes(hedge.as_mut_bytes()) + .expect("OS randomness for the signature hedge"); + hedge +} + macro_rules! context_sign { ($sign:ident, $pair:ty, $signature:ty, $sig_with_public:ty, $module:ident) => { /// Sign `message` under `context`, the way the connected runtime expects. @@ -33,8 +46,9 @@ macro_rules! context_sign { let secret = qp_rusty_crystals_dilithium::$module::SecretKey::from_bytes(pair.secret_bytes()) .expect("wallet secret key must parse"); - let signature = - secret.sign(message, context, None).expect("the context is well-formed"); + let signature = secret + .sign(message, context, Some(&hedge())) + .expect("the context is well-formed"); let signature = <$signature as TryFrom<&[u8]>>::try_from(signature.as_ref()) .expect("signature length is fixed"); <$sig_with_public>::new(signature, pair.public()) @@ -115,15 +129,32 @@ mod tests { assert_ne!(with_context.to_bytes(), without_context.to_bytes()); } - /// The pre-148 runtimes verify with no context at all, so the CLI must keep signing for them - /// the old way. `sp_core::Pair::sign` in the published `qp-dilithium-crypto` is that old way. + /// The pre-148 runtimes verify with no context at all, the way `sp_core::Pair::verify` in the + /// published `qp-dilithium-crypto` does, so the CLI must keep signing for them that way. #[test] - fn pre_148_runtimes_get_the_contextless_signature_pair_sign_makes() { + fn pre_148_runtimes_verify_the_contextless_signature() { let alice = qp_dilithium_crypto::crystal_alice(); - assert_eq!( - sign_ml_dsa_87(&alice, b"payload", None).to_bytes(), - ::sign(&alice, b"payload").to_bytes() - ); + let signature = sign_ml_dsa_87(&alice, b"payload", None); + assert!(::verify(&signature, b"payload", &alice.public())); + } + + /// Signing is hedged with fresh randomness: two signatures over one payload differ, so two + /// otherwise identical extrinsics never share a hash, and both still verify. + #[test] + fn two_signings_of_the_same_payload_differ_and_both_verify() { + let alice = qp_dilithium_crypto::crystal_alice(); + let first = sign_ml_dsa_87(&alice, b"payload", CTX); + let second = sign_ml_dsa_87(&alice, b"payload", CTX); + assert_ne!(first.to_bytes(), second.to_bytes()); + assert!(verify_ml_dsa_87(&first, b"payload", CTX)); + assert!(verify_ml_dsa_87(&second, b"payload", CTX)); + + let pair = Dilithium65Pair::from_seed(&[7u8; 32]).expect("seed is well-formed"); + let first = sign_ml_dsa_65(&pair, b"payload", CTX); + let second = sign_ml_dsa_65(&pair, b"payload", CTX); + assert_ne!(first.to_bytes(), second.to_bytes()); + assert!(verify_ml_dsa_65(&first, b"payload", CTX)); + assert!(verify_ml_dsa_65(&second, b"payload", CTX)); } #[test] diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 9c60299..7de4928 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -1490,7 +1490,7 @@ fn build_dilithium_claim( let secret = SecretKey::from_bytes(secret_key) .map_err(|_| QuantusError::Generic("invalid ML-DSA-87 secret key".into()))?; let signature = secret - .sign(&msg, Some(CLAIM_CONTEXT), None) + .sign(&msg, Some(CLAIM_CONTEXT), Some(&crate::chain::signing::hedge())) .map_err(|e| QuantusError::Generic(format!("ML-DSA sign failed: {e}")))?; let scheme = DilithiumHash::ALL .iter() diff --git a/src/cli/cold_signing.rs b/src/cli/cold_signing.rs index d7864e7..9e16c64 100644 --- a/src/cli/cold_signing.rs +++ b/src/cli/cold_signing.rs @@ -711,16 +711,13 @@ mod tests { use subxt::{client::ClientState, config::substrate::H256, utils::MultiAddress, OfflineClient}; fn test_client_state() -> ClientState { - let metadata_bytes: &[u8] = include_bytes!("../quantus_metadata.scale"); - let metadata = ::decode(&mut &metadata_bytes[..]) - .expect("valid metadata"); ClientState { genesis_hash: H256::from([0x49u8; 32]), runtime_version: subxt::client::RuntimeVersion { spec_version: 136, transaction_version: 3, }, - metadata, + metadata: crate::chain::test_metadata(), } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6796e39..bd1360f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -70,6 +70,11 @@ pub enum Commands { /// Manual nonce override (use with caution - must be exact next nonce for account) #[arg(long)] nonce: Option, + + /// Send with transfer_keep_alive instead of the default transfer_allow_death: the + /// transfer fails rather than reaping the sender below the existential deposit + #[arg(long)] + keep_alive: bool, }, /// Print the QR a cold wallet scans to sign a call for an account, and stop @@ -395,7 +400,7 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { from, to, amount, password, password_file, tip, nonce } => + Commands::Send { from, to, amount, password, password_file, tip, nonce, keep_alive } => send::handle_send_command( from, to, @@ -405,6 +410,7 @@ pub async fn execute_command( password_file, tip, nonce, + keep_alive, execution_mode, ) .await, diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index f579913..f7b1b49 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -136,6 +136,11 @@ pub enum ProposeSubcommand { /// Read password from file #[arg(long)] password_file: Option, + + /// Propose transfer_keep_alive instead of the default transfer_allow_death: the + /// transfer fails rather than reaping the multisig below the existential deposit + #[arg(long)] + keep_alive: bool, }, /// Propose a custom transaction (full flexibility) @@ -973,6 +978,7 @@ pub async fn handle_multisig_command( from, password, password_file, + keep_alive, } => handle_propose_transfer( address, @@ -982,6 +988,7 @@ pub async fn handle_multisig_command( from, password, password_file, + keep_alive, node_url, execution_mode, ) @@ -1359,13 +1366,15 @@ async fn handle_propose_transfer( from: String, password: Option, password_file: Option, + keep_alive: bool, node_url: &str, execution_mode: ExecutionMode, ) -> crate::error::Result<()> { log_print!("📝 {} Creating transfer proposal...", "MULTISIG".bright_magenta().bold()); // Resolve recipient address (wallet name or SS58) - let to_address = crate::cli::common::resolve_address(&to)?; + let (to_address, to_account_id) = + crate::cli::common::resolve_address_with_subxt_account_id(&to)?; // Parse amount (supports both human format "10" and raw "10000000000000") let amount_u128: u128 = parse_amount(&amount)?; @@ -1407,26 +1416,24 @@ async fn handle_propose_transfer( .high_security_accounts(multisig_account_id); let is_high_security = storage_at.fetch(&hs_query).await?.is_some(); + use subxt::tx::Payload; if is_high_security { + if keep_alive { + return Err(crate::error::QuantusError::Generic( + "--keep-alive does not apply to a high-security multisig: its transfers are proposed as ReversibleTransfers::schedule_transfer".to_string(), + )); + } // HS enabled: use ReversibleTransfers::schedule_transfer (delayed + reversible) log_print!( "🛡️ {} High-Security detected: using delayed transfer (ReversibleTransfers::schedule_transfer)", "HS".bright_green().bold() ); - // Build schedule_transfer call data directly - let (to_id, _) = SpAccountId32::from_ss58check_with_version(&to_address).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid recipient address: {:?}", e)) - })?; - let to_bytes: [u8; 32] = *to_id.as_ref(); - let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_bytes); let dest = subxt::utils::MultiAddress::Id(to_account_id); - let schedule_call = quantus_subxt::api::tx() .reversible_transfers() .schedule_transfer(dest, amount_u128); - use subxt::tx::Payload; let call_data = schedule_call .encode_call_data(&quantus_client.client().metadata()) .map_err(|e| { @@ -1446,7 +1453,13 @@ async fn handle_propose_transfer( ) .await } else { - // No HS: use standard Balances::transfer_allow_death + // No HS: the Balances transfer `quantus send` makes, proposed by name + let call = crate::cli::send::build_transfer_call_for_account_id( + to_account_id, + amount_u128, + keep_alive, + ); + let details = call.validation_details().expect("static balances call names itself"); let args_json = serde_json::to_string(&vec![ serde_json::Value::String(to_address), serde_json::Value::String(amount_u128.to_string()), @@ -1457,8 +1470,8 @@ async fn handle_propose_transfer( handle_propose( multisig_address, - "Balances".to_string(), - "transfer_allow_death".to_string(), + details.pallet_name.to_string(), + details.call_name.to_string(), Some(args_json), expiry, from, @@ -3335,6 +3348,7 @@ mod tests { #[cfg(test)] mod execute_call_tests { use super::*; + use crate::chain::test_metadata; use codec::Encode; use quantus_subxt::api::runtime_types::{ pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall, @@ -3370,12 +3384,6 @@ mod execute_call_tests { assert!(build_propose_tx(address, transfer_call().encode(), 5000).is_ok()); } - fn test_metadata() -> subxt::Metadata { - use codec::Decode; - let bytes: &[u8] = include_bytes!("../quantus_metadata.scale"); - subxt::Metadata::decode(&mut &bytes[..]).expect("valid checked-in metadata") - } - /// Indices come from the metadata, never from a hardcoded table. fn call_indices(md: &subxt::Metadata, pallet: &str, call: &str) -> (u8, u8) { let p = md.pallets().find(|p| p.name() == pallet).expect("pallet present"); diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index 2acf896..b3413f0 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -526,12 +526,7 @@ pub async fn handle_runtime_command( #[cfg(test)] mod tests { use super::*; - use codec::Decode; - - fn metadata() -> subxt::Metadata { - let bytes: &[u8] = include_bytes!("../quantus_metadata.scale"); - subxt::Metadata::decode(&mut &bytes[..]).expect("valid checked-in metadata") - } + use crate::chain::test_metadata as metadata; #[test] fn authorization_preimage_contains_only_the_runtime_hash() { diff --git a/src/cli/send.rs b/src/cli/send.rs index 05c0225..3bb6902 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -246,14 +246,18 @@ pub(crate) fn positive_tip_amount(tip: Option) -> Option { tip.filter(|tip_amount| *tip_amount > 0) } -fn build_transfer_call_for_account_id( +pub(crate) fn build_transfer_call_for_account_id( to_account_id: SubxtAccountId32, amount: u128, -) -> impl subxt::tx::Payload { - quantus_subxt::api::tx().balances().transfer_allow_death( - subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), - amount, - ) + keep_alive: bool, +) -> Box { + let dest = subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id); + let balances = quantus_subxt::api::tx().balances(); + if keep_alive { + Box::new(balances.transfer_keep_alive(dest, amount)) + } else { + Box::new(balances.transfer_allow_death(dest, amount)) + } } pub(crate) fn build_batch_transfer_call( @@ -470,7 +474,7 @@ pub async fn transfer_with_nonce( log_verbose!("✍️ Creating balance transfer extrinsic..."); - let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); + let transfer_call = build_transfer_call_for_account_id(to_account_id, amount, false); let submit_tip = positive_tip_amount(tip); // Submit the transaction with optional manual nonce @@ -593,6 +597,7 @@ pub async fn handle_send_command( password_file: Option, tip: Option, nonce: Option, + keep_alive: bool, execution_mode: crate::cli::common::ExecutionMode, ) -> Result<()> { // Create quantus chain client @@ -636,7 +641,7 @@ pub async fn handle_send_command( let effective_tip = effective_tip_amount(tip_amount); let submit_tip = positive_tip_amount(tip_amount); let exact_required = checked_add(amount, effective_tip, "required send balance")?; - let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); + let transfer_call = build_transfer_call_for_account_id(to_account_id, amount, keep_alive); ensure_balance_covers_call( &quantus_client, &signer, @@ -769,8 +774,9 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { use super::{ - build_batch_transfer_call, effective_tip_amount, format_balance, - limits_from_batched_calls_limit, parse_amount_with_decimals, + build_batch_transfer_call, build_transfer_call_for_account_id, effective_tip_amount, + format_balance, limits_from_batched_calls_limit, parse_amount_with_decimals, + SubxtAccountId32, }; use subxt::tx::Payload; @@ -788,6 +794,24 @@ mod tests { ); } + /// `send` keeps `transfer_allow_death` (call index 0) as its default; `--keep-alive` changes + /// the call index alone, to `transfer_keep_alive` (3). + #[test] + fn send_transfers_allow_death_unless_keep_alive() { + let metadata = crate::chain::test_metadata(); + let dest = SubxtAccountId32::from([7u8; 32]); + let allow_death = build_transfer_call_for_account_id(dest.clone(), 1, false) + .encode_call_data(&metadata) + .unwrap(); + let keep_alive = build_transfer_call_for_account_id(dest, 1, true) + .encode_call_data(&metadata) + .unwrap(); + let balances = metadata.pallet_by_name("Balances").unwrap().index(); + assert_eq!(allow_death[..2], [balances, 0], "default is transfer_allow_death"); + assert_eq!(keep_alive[..2], [balances, 3], "--keep-alive is transfer_keep_alive"); + assert_eq!(allow_death[2..], keep_alive[2..], "only the call index differs"); + } + #[test] fn parses_exact_decimal_amounts() { assert_eq!(parse_amount_with_decimals("0.1", 12).unwrap(), 100_000_000_000);