From 885bf8d0dd8915719db3611d890a0f50e304c108 Mon Sep 17 00:00:00 2001 From: illuzen Date: Sat, 12 Sep 2026 15:19:53 +0800 Subject: [PATCH 01/11] Add airdrop check and claim commands for Dilithium and wormhole proofs. Miners can match snapshot rows locally and submit ownership proofs without sending keys to the claim server. Co-authored-by: Cursor --- Cargo.lock | 55 ++- Cargo.toml | 3 + src/cli/airdrop.rs | 968 +++++++++++++++++++++++++++++++++++++++++ src/cli/common.rs | 4 + src/cli/mod.rs | 12 +- src/wallet/mod.rs | 7 + src/wallet/password.rs | 11 +- 7 files changed, 1044 insertions(+), 16 deletions(-) create mode 100644 src/cli/airdrop.rs diff --git a/Cargo.lock b/Cargo.lock index 6cdc0fb..00ce8b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1170,7 +1170,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1738,7 +1738,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1963,7 +1963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3934,7 +3934,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4679,6 +4679,39 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "qp-ownership-circuit" +version = "4.4.0" +source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +dependencies = [ + "anyhow", + "qp-ownership-inputs", + "qp-plonky2", + "qp-wormhole-circuit", + "qp-zk-circuits-common", +] + +[[package]] +name = "qp-ownership-inputs" +version = "4.4.0" +source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +dependencies = [ + "anyhow", + "qp-wormhole-inputs", +] + +[[package]] +name = "qp-ownership-prover" +version = "4.4.0" +source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +dependencies = [ + "anyhow", + "qp-ownership-circuit", + "qp-plonky2", + "qp-wormhole-circuit", + "qp-zk-circuits-common", +] + [[package]] name = "qp-plonky2" version = "1.5.5" @@ -4922,6 +4955,8 @@ dependencies = [ "parity-scale-codec", "qp-dilithium-crypto", "qp-human-checkphrase", + "qp-ownership-circuit", + "qp-ownership-prover", "qp-plonky2", "qp-plonky2-verifier", "qp-poseidon-core", @@ -5026,7 +5061,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5462,7 +5497,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5542,7 +5577,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 1.0.9", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7047,7 +7082,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7531,7 +7566,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if 1.0.4", + "cfg-if 0.1.10", "digest 0.10.7", "rand 0.8.6", "static_assertions", @@ -8163,7 +8198,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 64ee5cc..7f4cbb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,8 +122,11 @@ subxt-metadata = "0.44" # ZK proof generation (aligned with chain) anyhow = "1.0" +qp-ownership-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } +qp-ownership-prover = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } qp-plonky2 = { version = "1.5.5", default-features = false, features = ["rand", "std"] } qp-plonky2-verifier = { version = "1.5.5", default-features = false } +qp-poseidon-core = "3.1.0" qp-wormhole-aggregator = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["rayon", "std"] } qp-wormhole-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } qp-wormhole-circuit-builder = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0" } diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs new file mode 100644 index 0000000..9a3771d --- /dev/null +++ b/src/cli/airdrop.rs @@ -0,0 +1,968 @@ +use crate::{ + cli::{ + address_format::bytes_to_quantus_ss58, common::resolve_address_with_subxt_account_id, + wormhole::parse_secret_hex, + }, + error::{QuantusError, Result, WalletError}, + log_error, log_print, log_success, log_verbose, + wallet::{keystore::WalletType, password, DilithiumScheme, QuantumKeyPair, WalletManager}, +}; +use clap::Subcommand; +use colored::Colorize; +use qp_ownership_circuit::{CircuitInputs, Secret}; +use qp_rusty_crystals_dilithium::ml_dsa_87::SecretKey; +use qp_rusty_crystals_hdwallet::{derive_wormhole_from_mnemonic, QUANTUS_WORMHOLE_CHAIN_ID}; +use qp_zk_circuits_common::utils::BytesDigest; +use serde::{Deserialize, Serialize}; +use sp_core::crypto::{AccountId32, Ss58Codec}; +use std::{collections::HashMap, path::PathBuf, time::Duration}; + +const CLAIM_CONTEXT: &[u8] = b"qp-airdrop-claim-v1"; +const CLAIM_TTL_SECS: i64 = 10 * 60; +const DEFAULT_SERVER: &str = "http://127.0.0.1:8080"; +const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive = 0..=16; +const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact"; + +#[derive(Subcommand, Debug)] +pub enum AirdropCommands { + /// Show snapshot rows owned by this wallet or wormhole secret + Check { + /// Claim server base URL + #[arg(long, default_value = DEFAULT_SERVER)] + server: String, + + /// Hot wallet used to derive Dilithium (and HD wormhole) addresses + #[arg(long, short)] + wallet: Option, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + + /// File with a 32-byte hex wormhole secret (chmod 600) + #[arg(long)] + wormhole_secret_file: Option, + + /// HD wormhole index at round 0 (default: scan 0..=16) + #[arg(long)] + wormhole_index: Option, + }, + + /// Prove ownership and submit claims. Amounts come from the snapshot. + Claim { + /// Claim server base URL + #[arg(long, default_value = DEFAULT_SERVER)] + server: String, + + /// Hot wallet that signs Dilithium claims and/or derives HD wormhole secrets + #[arg(long, short)] + wallet: Option, + + /// Destination for the payout (wallet name or SS58). Defaults to --wallet. + #[arg(long, short)] + to: Option, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + + /// File with a 32-byte hex wormhole secret (chmod 600) + #[arg(long)] + wormhole_secret_file: Option, + + /// HD wormhole index at round 0 (default: scan 0..=16) + #[arg(long)] + wormhole_index: Option, + + /// Print matches and signed/proved payloads without POSTing + #[arg(long)] + dry_run: bool, + }, +} + +pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { + match command { + AirdropCommands::Check { + server, + wallet, + password, + password_file, + wormhole_secret_file, + wormhole_index, + } => + handle_check( + server, + wallet, + password, + password_file, + wormhole_secret_file, + wormhole_index, + ) + .await, + AirdropCommands::Claim { + server, + wallet, + to, + password, + password_file, + wormhole_secret_file, + wormhole_index, + dry_run, + } => + handle_claim( + server, + wallet, + to, + password, + password_file, + wormhole_secret_file, + wormhole_index, + dry_run, + ) + .await, + } +} + +async fn handle_check( + server: String, + wallet: Option, + password: Option, + password_file: Option, + wormhole_secret_file: Option, + wormhole_index: Option, +) -> Result<()> { + let snapshot = fetch_snapshot(&server).await?; + let credentials = collect_credentials( + wallet.as_deref(), + password, + password_file, + wormhole_secret_file.as_deref(), + wormhole_index, + )?; + if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() { + return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); + } + + let matches = find_matches(&snapshot, &credentials); + print_snapshot_header(&snapshot); + print_matches(&matches); + Ok(()) +} + +async fn handle_claim( + server: String, + wallet: Option, + to: Option, + password: Option, + password_file: Option, + wormhole_secret_file: Option, + wormhole_index: Option, + dry_run: bool, +) -> Result<()> { + let credentials = collect_credentials( + wallet.as_deref(), + password, + password_file, + wormhole_secret_file.as_deref(), + wormhole_index, + )?; + if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() { + return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); + } + + let claim_account = resolve_claim_account(to.as_deref(), wallet.as_deref(), &credentials)?; + let snapshot = fetch_snapshot(&server).await?; + let matches = find_matches(&snapshot, &credentials); + + print_snapshot_header(&snapshot); + print_matches(&matches); + log_print!("Payout destination: {}", bytes_to_quantus_ss58(&claim_account).bright_cyan()); + + if matches.is_empty() { + log_print!("No snapshot addresses to claim."); + return Ok(()); + } + + let client = http_client()?; + let mut claimed = 0u64; + let mut skipped = 0u64; + for found in &matches { + match submit_claim(&client, &server, found, &claim_account, &credentials, dry_run).await { + Ok(ClaimOutcome::Recorded { amount_hundredths }) => { + claimed = claimed.saturating_add(amount_hundredths); + }, + Ok(ClaimOutcome::Skipped) => skipped += 1, + Err(e) => { + log_error!("Failed {}: {e}", found.ss58); + skipped += 1; + }, + } + } + + if dry_run { + log_print!( + "Dry run finished. Would submit {} claim(s); skipped {}.", + matches.len().saturating_sub(skipped as usize), + skipped + ); + } else { + log_success!( + "Recorded {} QUAN across submitted claims ({} skipped).", + format_hundredths(claimed), + skipped + ); + } + Ok(()) +} + +struct Credentials { + dilithium: Option, + wormhole_secrets: Vec<([u8; 32], String)>, +} + +fn collect_credentials( + wallet: Option<&str>, + password: Option, + password_file: Option, + wormhole_secret_file: Option<&std::path::Path>, + wormhole_index: Option, +) -> Result { + let mut wormhole_secrets = Vec::new(); + let mut dilithium = None; + + if let Some(name) = wallet { + let (keypair, mnemonic) = load_wallet_material(name, password, password_file)?; + if keypair.scheme != DilithiumScheme::MlDsa87 { + log_print!( + "Wallet '{}' is {:?}; Dilithium airdrop claims require ML-DSA-87.", + name, + keypair.scheme + ); + } else { + dilithium = Some(keypair); + } + if let Some(mnemonic) = mnemonic.as_deref() { + wormhole_secrets.extend(derive_hd_wormhole_secrets(mnemonic, wormhole_index)?); + } else { + log_verbose!("Wallet '{}' has no mnemonic; HD wormhole derivation skipped", name); + } + } + + if let Some(path) = wormhole_secret_file { + let secret = read_wormhole_secret(path)?; + wormhole_secrets.push((secret, path.display().to_string())); + } + + Ok(Credentials { dilithium, wormhole_secrets }) +} + +fn load_wallet_material( + wallet_name: &str, + password: Option, + password_file: Option, +) -> Result<(QuantumKeyPair, Option)> { + let wallet_manager = WalletManager::new()?; + if wallet_manager.wallet_type(wallet_name)? == Some(WalletType::Cold) { + return Err(WalletError::ColdWalletNoKeys(wallet_name.to_string()).into()); + } + let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; + let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; + let mnemonic = wallet_data.take_mnemonic(); + Ok((wallet_data.take_keypair(), mnemonic)) +} + +fn derive_hd_wormhole_secrets( + mnemonic: &str, + wormhole_index: Option, +) -> Result> { + let indexes: Vec = match wormhole_index { + Some(index) => vec![index], + None => HD_WORMHOLE_INDEXES.collect(), + }; + let mut out = Vec::new(); + for index in indexes { + let path = format!("m/44'/{}/0'/0'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, index); + let pair = derive_wormhole_from_mnemonic(mnemonic, None, &path) + .map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?; + out.push((*pair.secret().as_bytes(), format!("hd {path}"))); + } + Ok(out) +} + +fn read_wormhole_secret(path: &std::path::Path) -> Result<[u8; 32]> { + let hex_str = password::read_secret_file( + path.to_str() + .ok_or_else(|| QuantusError::Generic("secret path is not UTF-8".into()))?, + "secret", + )?; + parse_secret_hex(&hex_str).map_err(QuantusError::Generic) +} + +fn resolve_claim_account( + to: Option<&str>, + wallet: Option<&str>, + credentials: &Credentials, +) -> Result<[u8; 32]> { + if let Some(to) = to { + let (_, account) = resolve_address_with_subxt_account_id(to)?; + return Ok(*account.as_ref()); + } + if let Some(keypair) = &credentials.dilithium { + let account = keypair.try_to_account_id_32()?; + return Ok(*account.as_ref()); + } + if let Some(name) = wallet { + let (_, account) = resolve_address_with_subxt_account_id(name)?; + return Ok(*account.as_ref()); + } + Err(QuantusError::Generic("--to is required when claiming without a Dilithium wallet".into())) +} + +#[derive(Clone, Debug)] +struct SnapshotFile { + version: u32, + sha256: String, + rows: Vec, + by_account: HashMap<[u8; 32], SnapshotRow>, +} + +#[derive(Clone, Debug, Deserialize)] +struct SnapshotRow { + address: String, + account: String, + amount_hundredths: u64, + testnets: Vec, + kind: String, +} + +#[derive(Deserialize)] +struct SnapshotWire { + version: u32, + sha256: String, + rows: Vec, +} + +#[derive(Clone, Debug)] +struct FoundReward { + account: [u8; 32], + ss58: String, + amount_hundredths: u64, + testnets: Vec, + kind: String, + scheme: &'static str, + source: RewardSource, +} + +#[derive(Clone, Debug)] +enum RewardSource { + Dilithium, + Wormhole { secret: [u8; 32], label: String }, +} + +fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec { + let mut found = Vec::new(); + if let Some(keypair) = &credentials.dilithium { + for scheme in DilithiumHash::ALL { + let address = scheme.derive(&keypair.public_key); + if let Some(row) = snapshot.by_account.get(&address) { + found.push(FoundReward { + account: address, + ss58: row.address.clone(), + amount_hundredths: row.amount_hundredths, + testnets: row.testnets.clone(), + kind: row.kind.clone(), + scheme: scheme.id(), + source: RewardSource::Dilithium, + }); + } + } + } + for (secret, label) in &credentials.wormhole_secrets { + for scheme in WormholeHash::ALL { + let derived = scheme.derive(secret); + if let Some(row) = snapshot.by_account.get(&derived.address) { + found.push(FoundReward { + account: derived.address, + ss58: row.address.clone(), + amount_hundredths: row.amount_hundredths, + testnets: row.testnets.clone(), + kind: row.kind.clone(), + scheme: scheme.id(), + source: RewardSource::Wormhole { secret: *secret, label: label.clone() }, + }); + } + } + } + found.sort_by(|a, b| a.ss58.cmp(&b.ss58).then(a.scheme.cmp(b.scheme))); + found.dedup_by(|a, b| a.account == b.account && a.scheme == b.scheme); + found +} + +fn print_snapshot_header(snapshot: &SnapshotFile) { + log_print!( + "Snapshot v{} ({}) β€” {} rewarded addresses", + snapshot.version, + &snapshot.sha256[..snapshot.sha256.len().min(12)], + snapshot.rows.len() + ); +} + +fn print_matches(matches: &[FoundReward]) { + if matches.is_empty() { + log_print!("No airdrop addresses found for the supplied credentials."); + return; + } + log_print!("{} snapshot match(es):", matches.len()); + for found in matches { + let claimable = match &found.source { + RewardSource::Dilithium => true, + RewardSource::Wormhole { .. } => found.scheme == CLAIMABLE_WORMHOLE_SCHEME, + }; + let note = if claimable { "claimable" } else { "not claimable yet" }; + log_print!( + " {} {} QUAN {} {} ({}) [{}]", + found.ss58.bright_cyan(), + format_hundredths(found.amount_hundredths), + found.testnets.join(","), + found.scheme, + found.kind, + note + ); + } +} + +enum ClaimOutcome { + Recorded { amount_hundredths: u64 }, + Skipped, +} + +async fn submit_claim( + client: &reqwest::Client, + server: &str, + found: &FoundReward, + claim_account: &[u8; 32], + credentials: &Credentials, + dry_run: bool, +) -> Result { + let body = match &found.source { + RewardSource::Dilithium => { + let keypair = credentials.dilithium.as_ref().ok_or_else(|| { + QuantusError::Generic("Dilithium match without a loaded wallet".into()) + })?; + ClaimBody::Dilithium(build_dilithium_claim(keypair, found.account, *claim_account)?) + }, + RewardSource::Wormhole { secret, label } => { + if found.scheme != CLAIMABLE_WORMHOLE_SCHEME { + log_print!( + "Skipping {} ({}) from {label}: server only accepts {CLAIMABLE_WORMHOLE_SCHEME}", + found.ss58, + found.scheme + ); + return Ok(ClaimOutcome::Skipped); + } + log_print!("Proving wormhole ownership for {}…", found.ss58.bright_cyan()); + ClaimBody::Wormhole(build_wormhole_claim(*secret, *claim_account).await?) + }, + }; + + if dry_run { + log_print!("Dry run: would POST {} ({})", found.ss58, found.scheme); + return Ok(ClaimOutcome::Recorded { amount_hundredths: found.amount_hundredths }); + } + + let url = format!("{}/claim", server.trim_end_matches('/')); + let response = client.post(&url).json(&body).send().await.map_err(http_err)?; + let status = response.status(); + let text = response.text().await.map_err(http_err)?; + if !status.is_success() { + return Err(QuantusError::Generic(format_server_error(status, &text))); + } + let recorded: ClaimResponse = serde_json::from_str(&text) + .map_err(|e| QuantusError::Generic(format!("claim JSON: {e}")))?; + log_success!( + "Recorded {} β†’ {} ({} QUAN)", + recorded.address.bright_cyan(), + recorded.claim_account.bright_green(), + format_hundredths(recorded.amount_hundredths) + ); + Ok(ClaimOutcome::Recorded { amount_hundredths: recorded.amount_hundredths }) +} + +fn build_dilithium_claim( + keypair: &QuantumKeyPair, + address: [u8; 32], + claim_account: [u8; 32], +) -> Result { + let expiry_unix = now_unix()?.saturating_add(CLAIM_TTL_SECS); + let msg = claim_message(&address, &claim_account, expiry_unix); + let secret = SecretKey::from_bytes(&keypair.private_key) + .map_err(|_| QuantusError::Generic("invalid ML-DSA-87 secret key".into()))?; + let signature = secret + .sign(&msg, Some(CLAIM_CONTEXT), None) + .map_err(|e| QuantusError::Generic(format!("ML-DSA sign failed: {e}")))?; + let scheme = DilithiumHash::ALL + .iter() + .find(|s| s.derive(&keypair.public_key) == address) + .ok_or_else(|| QuantusError::Generic("could not identify Dilithium hash scheme".into()))?; + Ok(DilithiumClaimBody { + scheme: scheme.id().to_string(), + address: bytes_to_quantus_ss58(&address), + claim_account: bytes_to_quantus_ss58(&claim_account), + public_key: hex::encode(&keypair.public_key), + signature: hex::encode(signature), + expiry_unix, + }) +} + +async fn build_wormhole_claim( + secret: [u8; 32], + claim_account: [u8; 32], +) -> Result { + let secret = Secret::try_from(secret) + .map_err(|e| QuantusError::Generic(format!("invalid wormhole secret: {e:?}")))?; + let claim = BytesDigest::try_from(claim_account.as_slice()) + .map_err(|e| QuantusError::Generic(format!("invalid claim account: {e:?}")))?; + let inputs = CircuitInputs::from_secret(secret, claim); + let proof = tokio::task::spawn_blocking(move || { + let prover = qp_ownership_prover::build_fresh().commit(&inputs)?; + prover.prove() + }) + .await + .map_err(|e| QuantusError::Generic(format!("ownership prover task failed: {e}")))? + .map_err(|e| QuantusError::Generic(format!("ownership proof failed: {e}")))?; + Ok(WormholeClaimBody { + proof_kind: "wormhole_rate8".into(), + proof: hex::encode(proof.to_bytes()), + }) +} + +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum ClaimBody { + Dilithium(DilithiumClaimBody), + Wormhole(WormholeClaimBody), +} + +#[derive(Serialize)] +struct DilithiumClaimBody { + scheme: String, + address: String, + claim_account: String, + public_key: String, + signature: String, + expiry_unix: i64, +} + +#[derive(Serialize)] +struct WormholeClaimBody { + proof_kind: String, + proof: String, +} + +#[derive(Deserialize)] +struct ClaimResponse { + address: String, + claim_account: String, + amount_hundredths: u64, +} + +#[derive(Deserialize)] +struct ErrorBody { + error: String, +} + +async fn fetch_snapshot(server: &str) -> Result { + let url = format!("{}/snapshot", server.trim_end_matches('/')); + log_verbose!("GET {url}"); + let response = http_client()?.get(&url).send().await.map_err(http_err)?; + let status = response.status(); + let text = response.text().await.map_err(http_err)?; + if !status.is_success() { + return Err(QuantusError::Generic(format_server_error(status, &text))); + } + let wire: SnapshotWire = serde_json::from_str(&text) + .map_err(|e| QuantusError::Generic(format!("snapshot JSON: {e}")))?; + let mut by_account = HashMap::new(); + for row in &wire.rows { + let account = parse_account_id(&row.account).or_else(|_| parse_account_id(&row.address))?; + by_account.insert(account, row.clone()); + } + Ok(SnapshotFile { version: wire.version, sha256: wire.sha256, rows: wire.rows, by_account }) +} + +fn parse_account_id(s: &str) -> Result<[u8; 32]> { + let s = s.trim(); + if s.starts_with("qz") { + let (account, _) = AccountId32::from_ss58check_with_version(s) + .map_err(|e| QuantusError::Generic(format!("invalid SS58 {s}: {e:?}")))?; + return Ok(*account.as_ref()); + } + let hex_str = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(hex_str) + .map_err(|e| QuantusError::Generic(format!("invalid account hex: {e}")))?; + bytes.try_into().map_err(|b: Vec| { + QuantusError::Generic(format!("account must be 32 bytes, got {}", b.len())) + }) +} + +fn http_client() -> Result { + reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| QuantusError::Generic(format!("HTTP client: {e}"))) +} + +fn http_err(e: reqwest::Error) -> QuantusError { + QuantusError::NetworkError(e.to_string()) +} + +fn format_server_error(status: reqwest::StatusCode, body: &str) -> String { + if let Ok(err) = serde_json::from_str::(body) { + format!("server {status}: {}", err.error) + } else { + format!("server {status}: {}", body.trim()) + } +} + +fn format_hundredths(amount: u64) -> String { + format!("{}.{:02}", amount / 100, amount % 100) +} + +fn now_unix() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .map_err(|e| QuantusError::Generic(format!("system clock: {e}"))) +} + +fn claim_message(address: &[u8; 32], claim_account: &[u8; 32], expiry_unix: i64) -> [u8; 72] { + let mut msg = [0u8; 72]; + msg[..32].copy_from_slice(address); + msg[32..64].copy_from_slice(claim_account); + msg[64..].copy_from_slice(&expiry_unix.to_be_bytes()); + msg +} + +#[derive(Clone, Copy, Debug)] +enum DilithiumHash { + V09Padded, + V10Padded, + Rate8HashBytes, +} + +impl DilithiumHash { + const ALL: &'static [Self] = &[Self::V09Padded, Self::V10Padded, Self::Rate8HashBytes]; + + fn id(self) -> &'static str { + match self { + Self::V09Padded => "dilithium-v09-padded", + Self::V10Padded => "dilithium-v10-padded", + Self::Rate8HashBytes => "dilithium-rate8-hash-bytes", + } + } + + fn derive(self, public_key: &[u8]) -> [u8; 32] { + match self { + Self::V09Padded => hash_padded_v09(public_key), + Self::V10Padded => hash_padded_v10(public_key), + Self::Rate8HashBytes => qp_poseidon_core::hash_bytes(public_key), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum WormholeHash { + V09Injective, + Rate4Compact, + Rate8Compact, + Rate4Injective, + Rate8Injective, +} + +impl WormholeHash { + const ALL: &'static [Self] = &[ + Self::V09Injective, + Self::Rate4Compact, + Self::Rate8Compact, + Self::Rate4Injective, + Self::Rate8Injective, + ]; + + fn id(self) -> &'static str { + match self { + Self::V09Injective => "wormhole-v09-injective", + Self::Rate4Compact => "wormhole-rate4-compact", + Self::Rate8Compact => "wormhole-rate8-compact", + Self::Rate4Injective => "wormhole-rate4-injective", + Self::Rate8Injective => "wormhole-rate8-injective", + } + } + + fn derive(self, secret: &[u8; 32]) -> DerivedWormhole { + let mut preimage = injective4(b"wormhole"); + preimage.extend(self.encode_secret(secret)); + let first_hash = self.sponge().hash_felts(&preimage); + let address = self.sponge().rehash(&first_hash); + DerivedWormhole { first_hash, address } + } + + fn encode_secret(self, secret: &[u8; 32]) -> Vec { + match self { + Self::V09Injective | Self::Rate4Injective | Self::Rate8Injective => injective4(secret), + Self::Rate4Compact | Self::Rate8Compact => compact8_decode(secret).to_vec(), + } + } + + fn sponge(self) -> Sponge { + match self { + Self::V09Injective => Sponge::Rate4Pad10PlusDomain, + Self::Rate4Compact | Self::Rate4Injective => Sponge::Rate4Pad10, + Self::Rate8Compact | Self::Rate8Injective => Sponge::Rate8Pad10, + } + } +} + +struct DerivedWormhole { + #[allow(dead_code)] + first_hash: [u8; 32], + address: [u8; 32], +} + +#[derive(Clone, Copy)] +enum Sponge { + Rate4Pad10PlusDomain, + Rate4Pad10, + Rate8Pad10, +} + +impl Sponge { + fn hash_felts(self, input: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { + match self { + Self::Rate4Pad10PlusDomain => hash_no_pad_v09(input), + Self::Rate4Pad10 => hash_felts_rate4_pad10(input), + Self::Rate8Pad10 => qp_poseidon_core::hash_to_bytes(input), + } + } + + fn rehash(self, digest: &[u8; 32]) -> [u8; 32] { + self.hash_felts(&compact8_decode(digest)) + } +} + +fn compact8_decode( + bytes: &[u8; 32], +) -> [qp_poseidon_core::Goldilocks; qp_poseidon_core::POSEIDON2_OUTPUT] { + qp_poseidon_core::serialization::bytes_to_digest_lossy(bytes) +} + +fn injective4(bytes: &[u8]) -> Vec { + use qp_poseidon_core::Goldilocks; + if bytes.is_empty() { + return Vec::new(); + } + const N: usize = 4; + let mut out = Vec::new(); + let num_chunks = bytes.len().div_ceil(N); + let mut unpadded = false; + for (i, chunk) in bytes.chunks(N).enumerate() { + let mut word = [0u8; N]; + if i == num_chunks - 1 { + if chunk.len() < N { + word[chunk.len()] = 1; + } else { + unpadded = true; + } + } + word[..chunk.len()].copy_from_slice(chunk); + out.push(Goldilocks::from_u64(u32::from_le_bytes(word) as u64)); + } + if unpadded { + out.push(Goldilocks::from_u64(1)); + } + out +} + +fn hash_no_pad_v09(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { + use qp_poseidon_core::{Goldilocks, Poseidon2, POSEIDON2_OUTPUT, SPONGE_WIDTH}; + const RATE_4: usize = 4; + let poseidon = Poseidon2::new(); + let mut state = [Goldilocks::ZERO; SPONGE_WIDTH]; + + if !x.is_empty() { + let num_chunks = x.chunks(RATE_4).len(); + let mut unpadded = false; + for (j, chunk) in x.chunks(RATE_4).enumerate() { + let mut block = [Goldilocks::ZERO; RATE_4]; + if j == num_chunks - 1 { + if chunk.len() < RATE_4 { + block[chunk.len()] = Goldilocks::ONE; + } else { + unpadded = true; + } + } + block[..chunk.len()].copy_from_slice(chunk); + for i in 0..RATE_4 { + state[i] += block[i]; + } + poseidon.permute_mut(&mut state); + } + if unpadded { + state[0] += Goldilocks::ONE; + poseidon.permute_mut(&mut state); + } + } + + state[3] += Goldilocks::ONE; + poseidon.permute_mut(&mut state); + + let digest: [Goldilocks; POSEIDON2_OUTPUT] = + state[..POSEIDON2_OUTPUT].try_into().expect("width > output"); + qp_poseidon_core::serialization::digest_to_bytes(&digest) +} + +fn hash_felts_rate4_pad10(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { + use qp_poseidon_core::{Goldilocks, Poseidon2, POSEIDON2_OUTPUT, SPONGE_WIDTH}; + const RATE_4: usize = 4; + let poseidon = Poseidon2::new(); + let mut state = [Goldilocks::ZERO; SPONGE_WIDTH]; + let mut buf = [Goldilocks::ZERO; RATE_4]; + let mut buf_len = 0usize; + + let absorb = |felt: Goldilocks, + state: &mut [Goldilocks; SPONGE_WIDTH], + buf: &mut [Goldilocks; RATE_4], + buf_len: &mut usize| { + buf[*buf_len] = felt; + *buf_len += 1; + if *buf_len == RATE_4 { + for i in 0..RATE_4 { + state[i] += buf[i]; + } + poseidon.permute_mut(state); + *buf = [Goldilocks::ZERO; RATE_4]; + *buf_len = 0; + } + }; + + for &felt in x { + absorb(felt, &mut state, &mut buf, &mut buf_len); + } + absorb(Goldilocks::ONE, &mut state, &mut buf, &mut buf_len); + while buf_len != 0 { + absorb(Goldilocks::ZERO, &mut state, &mut buf, &mut buf_len); + } + + let digest: [Goldilocks; POSEIDON2_OUTPUT] = + state[..POSEIDON2_OUTPUT].try_into().expect("width > output"); + qp_poseidon_core::serialization::digest_to_bytes(&digest) +} + +fn hash_padded_v09(bytes: &[u8]) -> [u8; 32] { + use qp_poseidon_core::Goldilocks; + const MIN_FELTS: usize = 190; + let mut felts = injective4(bytes); + let len = felts.len(); + felts.insert(0, Goldilocks::from_u64(len as u64)); + if len < MIN_FELTS { + felts.resize(MIN_FELTS, Goldilocks::ZERO); + } + hash_no_pad_v09(&felts) +} + +fn hash_padded_v10(bytes: &[u8]) -> [u8; 32] { + use qp_poseidon_core::Goldilocks; + const PAD: usize = 189; + let mut felts = injective4(bytes); + if felts.len() < PAD { + felts.resize(PAD, Goldilocks::ZERO); + } + hash_felts_rate4_pad10(&felts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(s: &str) -> [u8; 32] { + hex::decode(s).unwrap().try_into().unwrap() + } + + #[test] + fn claim_message_is_address_dest_expiry() { + let address = [1u8; 32]; + let dest = [2u8; 32]; + let expiry = 0x0102_0304_0506_0708i64; + let msg = claim_message(&address, &dest, expiry); + assert_eq!(&msg[..32], &address); + assert_eq!(&msg[32..64], &dest); + assert_eq!(&msg[64..], &expiry.to_be_bytes()); + } + + #[test] + fn current_wormhole_matches_hdwallet_golden() { + let secret = hex32("30051cfa3abd462d3bc26da2d660e90ba8af6080b7fe95d9fd3f3b37c7d9ce4b"); + let derived = WormholeHash::Rate8Compact.derive(&secret); + assert_eq!( + derived.first_hash, + hex32("890ff21aa4fda75dc56c6c322c164d3c21a18ca7853d368c28cf158affc8b5b1") + ); + assert_eq!( + derived.address, + hex32("6a2f0d3abe4390e0b05f6dea4ba10670676cda7c00d49526ddde59f16c85269f") + ); + } + + #[test] + fn historical_wormhole_schemes_diverge() { + let secret = [9u8; 32]; + let addrs: Vec<_> = WormholeHash::ALL.iter().map(|s| s.derive(&secret).address).collect(); + assert!(addrs.iter().any(|a| *a != addrs[0])); + } + + #[test] + fn dilithium_hash_schemes_diverge() { + let pk = [7u8; 64]; + let a = DilithiumHash::V09Padded.derive(&pk); + let b = DilithiumHash::V10Padded.derive(&pk); + let c = DilithiumHash::Rate8HashBytes.derive(&pk); + assert_ne!(a, b); + assert_ne!(b, c); + } + + #[test] + fn dilithium_claim_json_is_internally_tagged() { + let body = ClaimBody::Dilithium(DilithiumClaimBody { + scheme: "dilithium-v10-padded".into(), + address: "qzabc".into(), + claim_account: "qzdef".into(), + public_key: "aa".into(), + signature: "bb".into(), + expiry_unix: 42, + }); + let value = serde_json::to_value(&body).unwrap(); + assert_eq!(value["kind"], "dilithium"); + assert_eq!(value["scheme"], "dilithium-v10-padded"); + assert_eq!(value["expiry_unix"], 42); + assert!(value.get("Dilithium").is_none()); + } + + #[test] + fn wormhole_claim_json_is_internally_tagged() { + let body = ClaimBody::Wormhole(WormholeClaimBody { + proof_kind: "wormhole_rate8".into(), + proof: "cc".into(), + }); + let value = serde_json::to_value(&body).unwrap(); + assert_eq!(value["kind"], "wormhole"); + assert_eq!(value["proof_kind"], "wormhole_rate8"); + assert_eq!(value["proof"], "cc"); + } +} diff --git a/src/cli/common.rs b/src/cli/common.rs index 7690cea..5b966ef 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -360,6 +360,10 @@ fn resolve_protected_wallet_address( ) -> Result { use std::io::IsTerminal; + if let Ok(wallet_data) = wallet_manager.load_wallet(wallet_name, "") { + return wallet_data.keypair.try_to_account_id_ss58check(); + } + let password = if let Some(env_password) = crate::wallet::password::env_wallet_password(wallet_name) { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5e1ade2..31acc68 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,6 +3,7 @@ use clap::Subcommand; use colored::Colorize; pub mod address_format; +pub mod airdrop; pub mod batch; pub mod block; pub mod cold_signing; @@ -293,6 +294,10 @@ pub enum Commands { #[command(subcommand)] Wormhole(wormhole::WormholeCommands), + /// Claim testnet airdrop rewards + #[command(subcommand)] + Airdrop(airdrop::AirdropCommands), + /// Send random amounts to multiple addresses (total is distributed randomly) Multisend { /// Wallet name to send from @@ -510,6 +515,7 @@ pub async fn execute_command( Commands::Block(block_cmd) => block::handle_block_command(block_cmd, node_url).await, Commands::Wormhole(wormhole_cmd) => wormhole::handle_wormhole_command(wormhole_cmd, node_url, execution_mode).await, + Commands::Airdrop(airdrop_cmd) => airdrop::handle_airdrop_command(airdrop_cmd).await, Commands::Multisend { from, addresses_file, @@ -617,7 +623,7 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro log_verbose!("Creating wallet: {}", name.bright_green()); // Create wallet with a default password for testing - match wallet_manager.create_developer_wallet(name).await { + match wallet_manager.recreate_developer_wallet(name).await { Ok(wallet_info) => { log_success!("βœ… Created {}", name.bright_green()); log_success!(" Address: {}", wallet_info.address.bright_cyan()); @@ -635,6 +641,10 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro log_success!(" Created: {} wallets", created_count.to_string().bright_green()); log_print!(""); log_print!("πŸ’‘ {} You can now use these wallets:", "TIP".bright_blue().bold()); + log_print!(" Empty password (just press Enter if a prompt appears)."); + log_print!( + " Existing crystal_alice / crystal_bob / crystal_charlie files were replaced." + ); log_print!(" quantus send --from crystal_alice --to
--amount 1000"); log_print!(" quantus send --from crystal_bob --to
--amount 1000"); log_print!(" quantus send --from crystal_charlie --to
--amount 1000"); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 662558b..726c03d 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -244,6 +244,13 @@ impl WalletManager { }) } + /// Replace a `crystal_*` developer wallet so it matches current genesis keys + /// and the empty password. Used by `quantus developer create-test-wallets`. + pub async fn recreate_developer_wallet(&self, name: &str) -> Result { + let _ = self.delete_wallet(name)?; + self.create_developer_wallet(name).await + } + /// Export a wallet's mnemonic phrase pub fn export_mnemonic(&self, name: &str, password: Option<&str>) -> Result { // Cold wallets have nothing to export; check before prompting for a password. diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 3283dc6..f802432 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -187,11 +187,8 @@ pub fn get_wallet_password( return read_password_file(&file_path); } - if let Some(env_password) = password_from_env(wallet_name) { - return Ok(env_password); - } - - // Try empty password first (for development wallets) + // Developer wallets (crystal_*) use an empty password. Try that before env + // or a prompt so a leftover QUANTUS_WALLET_PASSWORD does not force a prompt. log_verbose!("πŸ”‘ Trying empty password first..."); let wallet_manager = WalletManager::new()?; if wallet_manager.load_wallet(wallet_name, "").is_ok() { @@ -199,6 +196,10 @@ pub fn get_wallet_password( return Ok("".to_string()); } + if let Some(env_password) = password_from_env(wallet_name) { + return Ok(env_password); + } + get_password_from_user(&format!("Enter password for wallet '{wallet_name}'")) } From 27cafd4df3bff04c75a6e9a8187de5b0fc30d7b9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 09:05:29 +0800 Subject: [PATCH 02/11] Address review: zeroize secrets, fail on submission errors, fix defaults. - Wormhole spend secrets live in a move-only SpendSecret (zeroize on drop, redacted Debug); matches reference them by index instead of copying. The mnemonic and secret-file hex are zeroized after derivation. - Any claim submission failure now fails the command after the batch summary; intentionally unsupported schemes still count as skipped. - The unlocked wallet's account id is kept as the default --to, so ML-DSA-65 wallets with --password-file no longer reopen the wallet (prompt/failure). - --dry-run prints the serialized claim payload as documented. Co-authored-by: Cursor --- src/cli/airdrop.rs | 196 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 155 insertions(+), 41 deletions(-) diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 9a3771d..ce14b14 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -178,7 +178,7 @@ async fn handle_claim( return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); } - let claim_account = resolve_claim_account(to.as_deref(), wallet.as_deref(), &credentials)?; + let claim_account = resolve_claim_account(to.as_deref(), &credentials)?; let snapshot = fetch_snapshot(&server).await?; let matches = find_matches(&snapshot, &credentials); @@ -193,39 +193,83 @@ async fn handle_claim( let client = http_client()?; let mut claimed = 0u64; - let mut skipped = 0u64; + let mut recorded = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; for found in &matches { match submit_claim(&client, &server, found, &claim_account, &credentials, dry_run).await { Ok(ClaimOutcome::Recorded { amount_hundredths }) => { + recorded += 1; claimed = claimed.saturating_add(amount_hundredths); }, Ok(ClaimOutcome::Skipped) => skipped += 1, Err(e) => { log_error!("Failed {}: {e}", found.ss58); - skipped += 1; + failed += 1; }, } } + finish_claims(dry_run, claimed, recorded, skipped, failed) +} +/// Print the batch summary. Any submission failure makes the command fail so +/// unattended callers see a non-zero exit; intentionally skipped schemes do +/// not. +fn finish_claims( + dry_run: bool, + claimed_hundredths: u64, + recorded: usize, + skipped: usize, + failed: usize, +) -> Result<()> { if dry_run { log_print!( - "Dry run finished. Would submit {} claim(s); skipped {}.", - matches.len().saturating_sub(skipped as usize), - skipped + "Dry run finished. Would submit {recorded} claim(s); {skipped} skipped; {failed} failed." ); - } else { + } else if failed == 0 { log_success!( - "Recorded {} QUAN across submitted claims ({} skipped).", - format_hundredths(claimed), - skipped + "Recorded {} QUAN across {recorded} claim(s); {skipped} skipped.", + format_hundredths(claimed_hundredths) + ); + } else { + log_print!( + "Recorded {} QUAN across {recorded} claim(s); {skipped} skipped; {failed} failed.", + format_hundredths(claimed_hundredths) ); } + if failed > 0 { + return Err(QuantusError::Generic(format!("{failed} claim submission(s) failed"))); + } Ok(()) } +/// Move-only wormhole spend secret. Zeroized on drop; Debug never prints it. +struct SpendSecret([u8; 32]); + +impl SpendSecret { + fn bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl Drop for SpendSecret { + fn drop(&mut self) { + crate::wallet::keystore::zeroize_bytes(&mut self.0); + } +} + +impl std::fmt::Debug for SpendSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SpendSecret()") + } +} + struct Credentials { dilithium: Option, - wormhole_secrets: Vec<([u8; 32], String)>, + /// AccountId of the unlocked wallet, kept so the default `--to` never + /// reopens the wallet (which would lose `--password-file`). + wallet_account: Option<[u8; 32]>, + wormhole_secrets: Vec<(SpendSecret, String)>, } fn collect_credentials( @@ -237,9 +281,11 @@ fn collect_credentials( ) -> Result { let mut wormhole_secrets = Vec::new(); let mut dilithium = None; + let mut wallet_account = None; if let Some(name) = wallet { let (keypair, mnemonic) = load_wallet_material(name, password, password_file)?; + wallet_account = Some(*keypair.try_to_account_id_32()?.as_ref()); if keypair.scheme != DilithiumScheme::MlDsa87 { log_print!( "Wallet '{}' is {:?}; Dilithium airdrop claims require ML-DSA-87.", @@ -249,8 +295,10 @@ fn collect_credentials( } else { dilithium = Some(keypair); } - if let Some(mnemonic) = mnemonic.as_deref() { - wormhole_secrets.extend(derive_hd_wormhole_secrets(mnemonic, wormhole_index)?); + if let Some(mut mnemonic) = mnemonic { + let derived = derive_hd_wormhole_secrets(&mnemonic, wormhole_index); + crate::wallet::keystore::zeroize_string(&mut mnemonic); + wormhole_secrets.extend(derived?); } else { log_verbose!("Wallet '{}' has no mnemonic; HD wormhole derivation skipped", name); } @@ -261,7 +309,7 @@ fn collect_credentials( wormhole_secrets.push((secret, path.display().to_string())); } - Ok(Credentials { dilithium, wormhole_secrets }) + Ok(Credentials { dilithium, wallet_account, wormhole_secrets }) } fn load_wallet_material( @@ -282,7 +330,7 @@ fn load_wallet_material( fn derive_hd_wormhole_secrets( mnemonic: &str, wormhole_index: Option, -) -> Result> { +) -> Result> { let indexes: Vec = match wormhole_index { Some(index) => vec![index], None => HD_WORMHOLE_INDEXES.collect(), @@ -292,38 +340,31 @@ fn derive_hd_wormhole_secrets( let path = format!("m/44'/{}/0'/0'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, index); let pair = derive_wormhole_from_mnemonic(mnemonic, None, &path) .map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?; - out.push((*pair.secret().as_bytes(), format!("hd {path}"))); + out.push((SpendSecret(*pair.secret().as_bytes()), format!("hd {path}"))); } Ok(out) } -fn read_wormhole_secret(path: &std::path::Path) -> Result<[u8; 32]> { - let hex_str = password::read_secret_file( +fn read_wormhole_secret(path: &std::path::Path) -> Result { + let mut hex_str = password::read_secret_file( path.to_str() .ok_or_else(|| QuantusError::Generic("secret path is not UTF-8".into()))?, "secret", )?; - parse_secret_hex(&hex_str).map_err(QuantusError::Generic) + let parsed = parse_secret_hex(&hex_str); + crate::wallet::keystore::zeroize_string(&mut hex_str); + parsed.map(SpendSecret).map_err(QuantusError::Generic) } -fn resolve_claim_account( - to: Option<&str>, - wallet: Option<&str>, - credentials: &Credentials, -) -> Result<[u8; 32]> { +fn resolve_claim_account(to: Option<&str>, credentials: &Credentials) -> Result<[u8; 32]> { if let Some(to) = to { let (_, account) = resolve_address_with_subxt_account_id(to)?; return Ok(*account.as_ref()); } - if let Some(keypair) = &credentials.dilithium { - let account = keypair.try_to_account_id_32()?; - return Ok(*account.as_ref()); - } - if let Some(name) = wallet { - let (_, account) = resolve_address_with_subxt_account_id(name)?; - return Ok(*account.as_ref()); + if let Some(account) = credentials.wallet_account { + return Ok(account); } - Err(QuantusError::Generic("--to is required when claiming without a Dilithium wallet".into())) + Err(QuantusError::Generic("--to is required when claiming without --wallet".into())) } #[derive(Clone, Debug)] @@ -361,10 +402,12 @@ struct FoundReward { source: RewardSource, } +/// Where the matching key came from. Holds an index into +/// `Credentials::wormhole_secrets` rather than a copy of the secret. #[derive(Clone, Debug)] enum RewardSource { Dilithium, - Wormhole { secret: [u8; 32], label: String }, + Wormhole { secret_index: usize, label: String }, } fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec { @@ -385,9 +428,9 @@ fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec Vec { + RewardSource::Wormhole { secret_index, label } => { if found.scheme != CLAIMABLE_WORMHOLE_SCHEME { log_print!( "Skipping {} ({}) from {label}: server only accepts {CLAIMABLE_WORMHOLE_SCHEME}", @@ -468,13 +511,19 @@ async fn submit_claim( ); return Ok(ClaimOutcome::Skipped); } + let (secret, _) = credentials.wormhole_secrets.get(*secret_index).ok_or_else(|| { + QuantusError::Generic("wormhole secret index out of range".into()) + })?; log_print!("Proving wormhole ownership for {}…", found.ss58.bright_cyan()); - ClaimBody::Wormhole(build_wormhole_claim(*secret, *claim_account).await?) + ClaimBody::Wormhole(build_wormhole_claim(secret, *claim_account).await?) }, }; if dry_run { - log_print!("Dry run: would POST {} ({})", found.ss58, found.scheme); + let json = serde_json::to_string_pretty(&body) + .map_err(|e| QuantusError::Generic(format!("claim JSON: {e}")))?; + log_print!("Dry run: would POST {} ({}):", found.ss58, found.scheme); + log_print!("{json}"); return Ok(ClaimOutcome::Recorded { amount_hundredths: found.amount_hundredths }); } @@ -523,10 +572,10 @@ fn build_dilithium_claim( } async fn build_wormhole_claim( - secret: [u8; 32], + secret: &SpendSecret, claim_account: [u8; 32], ) -> Result { - let secret = Secret::try_from(secret) + let secret = Secret::try_from(*secret.bytes()) .map_err(|e| QuantusError::Generic(format!("invalid wormhole secret: {e:?}")))?; let claim = BytesDigest::try_from(claim_account.as_slice()) .map_err(|e| QuantusError::Generic(format!("invalid claim account: {e:?}")))?; @@ -965,4 +1014,69 @@ mod tests { assert_eq!(value["proof_kind"], "wormhole_rate8"); assert_eq!(value["proof"], "cc"); } + + #[test] + fn spend_secret_debug_is_redacted() { + let secret = SpendSecret([0xAB; 32]); + let debug = format!("{secret:?}"); + assert!(!debug.contains("ab"), "debug output must not leak secret bytes: {debug}"); + assert!(!debug.contains("171"), "debug output must not leak secret bytes: {debug}"); + assert!(debug.contains("redacted")); + } + + #[test] + fn reward_source_debug_has_no_secret_material() { + let source = RewardSource::Wormhole { secret_index: 0, label: "hd m/44'".into() }; + let debug = format!("{source:?}"); + assert!(debug.contains("secret_index")); + } + + #[test] + fn resolve_claim_account_prefers_to_over_wallet_account() { + let credentials = Credentials { + dilithium: None, + wallet_account: Some([5u8; 32]), + wormhole_secrets: Vec::new(), + }; + let dest = [7u8; 32]; + let resolved = + resolve_claim_account(Some(&bytes_to_quantus_ss58(&dest)), &credentials).unwrap(); + assert_eq!(resolved, dest); + } + + #[test] + fn resolve_claim_account_defaults_to_unlocked_wallet_without_reopening() { + // The wallet account must come from Credentials (captured at unlock, + // works for ML-DSA-65 + --password-file), not from re-resolving the + // wallet name, which cannot see --password-file. + let credentials = Credentials { + dilithium: None, + wallet_account: Some([5u8; 32]), + wormhole_secrets: Vec::new(), + }; + assert_eq!(resolve_claim_account(None, &credentials).unwrap(), [5u8; 32]); + } + + #[test] + fn resolve_claim_account_requires_to_without_wallet() { + let credentials = + Credentials { dilithium: None, wallet_account: None, wormhole_secrets: Vec::new() }; + assert!(resolve_claim_account(None, &credentials).is_err()); + } + + #[test] + fn finish_claims_fails_when_all_submissions_failed() { + assert!(finish_claims(false, 0, 0, 0, 3).is_err()); + } + + #[test] + fn finish_claims_fails_on_partial_failure() { + assert!(finish_claims(false, 150, 1, 1, 1).is_err()); + } + + #[test] + fn finish_claims_succeeds_with_skips_but_no_failures() { + assert!(finish_claims(false, 150, 1, 2, 0).is_ok()); + assert!(finish_claims(true, 0, 1, 0, 0).is_ok()); + } } From a967d9d3d0f57ef900b6a782cceff9b9f5b177e9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 09:53:18 +0800 Subject: [PATCH 03/11] Use the real qp-poseidon-core 0.9.5 crate for v09 schemes The v0.9.x permutation was generated with ChaCha8 (seed 0x189189189189189) and differs from the current constants, so reconstructing the v09 sponge on the current permutation produced wrong addresses. Derive v09 Dilithium and wormhole addresses through the original crate and pin them with vectors from the tagged v0.9.5 source. Co-authored-by: Cursor --- Cargo.lock | 179 +++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 + src/cli/airdrop.rs | 77 +++++++------------ 3 files changed, 192 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00ce8b5..bb2122c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1170,7 +1170,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1738,7 +1738,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1963,7 +1963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3934,7 +3934,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4135,6 +4135,122 @@ dependencies = [ "sdl2", ] +[[package]] +name = "p3-dft" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b2764a3982d22d62aa933c8de6f9d71d8a474c9110b69e675dea1887bdeffc" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "tracing", +] + +[[package]] +name = "p3-field" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc13a73509fe09c67b339951ca8d4cc6e61c9bf08c130dbc90dda52452918cc2" +dependencies = [ + "itertools 0.14.0", + "num-bigint", + "p3-maybe-rayon", + "p3-util", + "paste", + "rand 0.9.5", + "serde", + "tracing", +] + +[[package]] +name = "p3-goldilocks" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552849f6309ffde34af0d31aa9a2d0a549cb0ec138d9792bfbf4a17800742362" +dependencies = [ + "num-bigint", + "p3-dft", + "p3-field", + "p3-mds", + "p3-poseidon2", + "p3-symmetric", + "p3-util", + "paste", + "rand 0.9.5", + "serde", +] + +[[package]] +name = "p3-matrix" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e1e9f69c2fe15768b3ceb2915edb88c47398aa22c485d8163deab2a47fe194" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "p3-maybe-rayon", + "p3-util", + "rand 0.9.5", + "serde", + "tracing", + "transpose", +] + +[[package]] +name = "p3-maybe-rayon" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f765046b763d046728b3246b690f81dfa7ccd7523b7a1582c74f616fbce6a0" + +[[package]] +name = "p3-mds" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c90541c6056712daf2ee69ec328db8b5605ae8dbafe60226c8eb75eaac0e1f9" +dependencies = [ + "p3-dft", + "p3-field", + "p3-symmetric", + "p3-util", + "rand 0.9.5", +] + +[[package]] +name = "p3-poseidon2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88e9f053f120a78ad27e9c1991a0ea547777328ca24025c42364d6ee2667d59a" +dependencies = [ + "p3-field", + "p3-mds", + "p3-symmetric", + "p3-util", + "rand 0.9.5", +] + +[[package]] +name = "p3-symmetric" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d5db8f05a26d706dfd8aaf7aa4272ca4f3e7a075db897ec7108f24fad78759" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "serde", +] + +[[package]] +name = "p3-util" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dfee67245d9ce78a15176728da2280032f0a84b5819a39a953e7ec03cfd9bd7" +dependencies = [ + "serde", +] + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -4656,7 +4772,7 @@ checksum = "bb9273e5fb0af1d9444f2d6f1ac1c89fe990a0ee182c2d77eff3e860376f2b71" dependencies = [ "log", "parity-scale-codec", - "qp-poseidon-core", + "qp-poseidon-core 3.1.0", "qp-rusty-crystals-dilithium", "qp-rusty-crystals-hdwallet", "scale-info", @@ -4732,7 +4848,7 @@ dependencies = [ "qp-plonky2-core", "qp-plonky2-field", "qp-plonky2-verifier", - "qp-poseidon-core", + "qp-poseidon-core 3.1.0", "rand 0.10.1", "serde", "static_assertions", @@ -4796,12 +4912,26 @@ dependencies = [ "plonky2_util", "qp-plonky2-core", "qp-plonky2-field", - "qp-poseidon-core", + "qp-poseidon-core 3.1.0", "serde", "static_assertions", "unroll", ] +[[package]] +name = "qp-poseidon-core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec326fc2631a929de09a38af2613a3db5230882c12a2f68205693ec632751e8b" +dependencies = [ + "p3-field", + "p3-goldilocks", + "p3-poseidon2", + "p3-symmetric", + "rand 0.9.5", + "rand_chacha 0.9.0", +] + [[package]] name = "qp-poseidon-core" version = "3.1.0" @@ -4827,7 +4957,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "hex-literal", - "qp-poseidon-core", + "qp-poseidon-core 3.1.0", "qp-rusty-crystals-dilithium", "serde", "serde_json", @@ -4919,7 +5049,7 @@ source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2cee dependencies = [ "anyhow", "qp-plonky2", - "qp-poseidon-core", + "qp-poseidon-core 3.1.0", "qp-wormhole-inputs", "rand 0.8.6", "serde", @@ -4959,7 +5089,8 @@ dependencies = [ "qp-ownership-prover", "qp-plonky2", "qp-plonky2-verifier", - "qp-poseidon-core", + "qp-poseidon-core 0.9.5", + "qp-poseidon-core 3.1.0", "qp-rusty-crystals-dilithium", "qp-rusty-crystals-hdwallet", "qp-wormhole-aggregator", @@ -5061,7 +5192,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5497,7 +5628,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5577,7 +5708,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs 1.0.9", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6751,6 +6882,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -7082,7 +7219,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7527,6 +7664,16 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "trie-db" version = "0.30.1" @@ -7566,7 +7713,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if 0.1.10", + "cfg-if 1.0.4", "digest 0.10.7", "rand 0.8.6", "static_assertions", @@ -8198,7 +8345,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7f4cbb4..8aa2870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,8 @@ qp-ownership-prover = { version = "4.4.0", git = "https://github.com/Quantus-Net qp-plonky2 = { version = "1.5.5", default-features = false, features = ["rand", "std"] } qp-plonky2-verifier = { version = "1.5.5", default-features = false } qp-poseidon-core = "3.1.0" +# Resonance-era (v0.9.x) permutation constants differ from the current ones. +qp-poseidon-core-v09 = { package = "qp-poseidon-core", version = "=0.9.5" } qp-wormhole-aggregator = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["rayon", "std"] } qp-wormhole-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } qp-wormhole-circuit-builder = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0" } diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index ce14b14..6ba8bd5 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -10,6 +10,7 @@ use crate::{ use clap::Subcommand; use colored::Colorize; use qp_ownership_circuit::{CircuitInputs, Secret}; +use qp_poseidon_core_v09 as v09; use qp_rusty_crystals_dilithium::ml_dsa_87::SecretKey; use qp_rusty_crystals_hdwallet::{derive_wormhole_from_mnemonic, QUANTUS_WORMHOLE_CHAIN_ID}; use qp_zk_circuits_common::utils::BytesDigest; @@ -756,6 +757,14 @@ impl WormholeHash { } fn derive(self, secret: &[u8; 32]) -> DerivedWormhole { + if matches!(self, Self::V09Injective) { + let core = v09::Poseidon2Core::new(); + let mut preimage = v09::injective_bytes_to_felts(b"wormhole"); + preimage.extend(v09::injective_bytes_to_felts(secret)); + let first_hash = core.hash_no_pad(preimage); + let address = core.hash_no_pad(v09::digest_bytes_to_felts(&first_hash)); + return DerivedWormhole { first_hash, address }; + } let mut preimage = injective4(b"wormhole"); preimage.extend(self.encode_secret(secret)); let first_hash = self.sponge().hash_felts(&preimage); @@ -772,7 +781,7 @@ impl WormholeHash { fn sponge(self) -> Sponge { match self { - Self::V09Injective => Sponge::Rate4Pad10PlusDomain, + Self::V09Injective => unreachable!("v09 uses its own field type; see derive()"), Self::Rate4Compact | Self::Rate4Injective => Sponge::Rate4Pad10, Self::Rate8Compact | Self::Rate8Injective => Sponge::Rate8Pad10, } @@ -787,7 +796,6 @@ struct DerivedWormhole { #[derive(Clone, Copy)] enum Sponge { - Rate4Pad10PlusDomain, Rate4Pad10, Rate8Pad10, } @@ -795,7 +803,6 @@ enum Sponge { impl Sponge { fn hash_felts(self, input: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { match self { - Self::Rate4Pad10PlusDomain => hash_no_pad_v09(input), Self::Rate4Pad10 => hash_felts_rate4_pad10(input), Self::Rate8Pad10 => qp_poseidon_core::hash_to_bytes(input), } @@ -839,44 +846,6 @@ fn injective4(bytes: &[u8]) -> Vec { out } -fn hash_no_pad_v09(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { - use qp_poseidon_core::{Goldilocks, Poseidon2, POSEIDON2_OUTPUT, SPONGE_WIDTH}; - const RATE_4: usize = 4; - let poseidon = Poseidon2::new(); - let mut state = [Goldilocks::ZERO; SPONGE_WIDTH]; - - if !x.is_empty() { - let num_chunks = x.chunks(RATE_4).len(); - let mut unpadded = false; - for (j, chunk) in x.chunks(RATE_4).enumerate() { - let mut block = [Goldilocks::ZERO; RATE_4]; - if j == num_chunks - 1 { - if chunk.len() < RATE_4 { - block[chunk.len()] = Goldilocks::ONE; - } else { - unpadded = true; - } - } - block[..chunk.len()].copy_from_slice(chunk); - for i in 0..RATE_4 { - state[i] += block[i]; - } - poseidon.permute_mut(&mut state); - } - if unpadded { - state[0] += Goldilocks::ONE; - poseidon.permute_mut(&mut state); - } - } - - state[3] += Goldilocks::ONE; - poseidon.permute_mut(&mut state); - - let digest: [Goldilocks; POSEIDON2_OUTPUT] = - state[..POSEIDON2_OUTPUT].try_into().expect("width > output"); - qp_poseidon_core::serialization::digest_to_bytes(&digest) -} - fn hash_felts_rate4_pad10(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { use qp_poseidon_core::{Goldilocks, Poseidon2, POSEIDON2_OUTPUT, SPONGE_WIDTH}; const RATE_4: usize = 4; @@ -914,16 +883,10 @@ fn hash_felts_rate4_pad10(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { qp_poseidon_core::serialization::digest_to_bytes(&digest) } +// The v0.9.x permutation used different round constants than the current one, +// so v09 derivations go through the real historical crate. fn hash_padded_v09(bytes: &[u8]) -> [u8; 32] { - use qp_poseidon_core::Goldilocks; - const MIN_FELTS: usize = 190; - let mut felts = injective4(bytes); - let len = felts.len(); - felts.insert(0, Goldilocks::from_u64(len as u64)); - if len < MIN_FELTS { - felts.resize(MIN_FELTS, Goldilocks::ZERO); - } - hash_no_pad_v09(&felts) + v09::Poseidon2Core::new().hash_padded(bytes) } fn hash_padded_v10(bytes: &[u8]) -> [u8; 32] { @@ -969,6 +932,20 @@ mod tests { ); } + /// Vectors computed with qp-poseidon-core 0.9.5 (git tag v0.9.5); the `[0]` + /// digest was also independently reproduced from the original tagged source. + #[test] + fn v09_schemes_match_original_crate_vectors() { + assert_eq!( + DilithiumHash::V09Padded.derive(&[0u8]), + hex32("b17b423096da9ebd57af5038b490257d9c492e64059c0ccff23f44e6293213d4") + ); + assert_eq!( + WormholeHash::V09Injective.derive(&[42u8; 32]).address, + hex32("f4e231ede747e9ca2da9528add147ee488651a0df72b00313b0a8e6b76388fea") + ); + } + #[test] fn historical_wormhole_schemes_diverge() { let secret = [9u8; 32]; From 789237846f1f9e598561fc1b49fd61b222a19487 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 14:01:03 +0800 Subject: [PATCH 04/11] Match pre-0.9.5 Resonance Dilithium addresses The claim matcher started at qp-poseidon 0.9.5, but shipped Resonance chains also used poseidon-resonance 0.8.0 (legacy plonky2 Poseidon, 8-byte limbs padded to 73 felts) and qp-poseidon 0.9.1 (4-byte limbs padded to 188). Derive both so early miners' rewards are found, mirroring the scheme ids the claim server accepts. Golden vectors come from the exact crates the chains pinned. Co-authored-by: Cursor --- src/cli/airdrop.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 6ba8bd5..08bbdd3 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -703,16 +703,26 @@ fn claim_message(address: &[u8; 32], claim_account: &[u8; 32], expiry_unix: i64) #[derive(Clone, Copy, Debug)] enum DilithiumHash { + V08Padded, + V091Padded, V09Padded, V10Padded, Rate8HashBytes, } impl DilithiumHash { - const ALL: &'static [Self] = &[Self::V09Padded, Self::V10Padded, Self::Rate8HashBytes]; + const ALL: &'static [Self] = &[ + Self::V08Padded, + Self::V091Padded, + Self::V09Padded, + Self::V10Padded, + Self::Rate8HashBytes, + ]; fn id(self) -> &'static str { match self { + Self::V08Padded => "dilithium-v08-padded", + Self::V091Padded => "dilithium-v091-padded", Self::V09Padded => "dilithium-v09-padded", Self::V10Padded => "dilithium-v10-padded", Self::Rate8HashBytes => "dilithium-rate8-hash-bytes", @@ -721,6 +731,8 @@ impl DilithiumHash { fn derive(self, public_key: &[u8]) -> [u8; 32] { match self { + Self::V08Padded => hash_padded_legacy(public_key, 8, 73), + Self::V091Padded => hash_padded_legacy(public_key, 4, 188), Self::V09Padded => hash_padded_v09(public_key), Self::V10Padded => hash_padded_v10(public_key), Self::Rate8HashBytes => qp_poseidon_core::hash_bytes(public_key), @@ -889,6 +901,33 @@ fn hash_padded_v09(bytes: &[u8]) -> [u8; 32] { v09::Poseidon2Core::new().hash_padded(bytes) } +// Pre-0.9.5 Resonance AccountId hash: legacy plonky2 Poseidon (unchanged in +// the current qp-plonky2) over little-endian limbs, zero-padded to a fixed +// preimage length. poseidon-resonance 0.8.0 used 8-byte limbs and 73 felts; +// qp-poseidon 0.9.1 used 4-byte limbs and 188 felts. +fn hash_padded_legacy(bytes: &[u8], bytes_per_felt: usize, pad_to: usize) -> [u8; 32] { + use plonky2::{ + field::{goldilocks_field::GoldilocksField, types::Field}, + plonk::config::{GenericHashOut, Hasher}, + }; + + let mut felts: Vec = bytes + .chunks(bytes_per_felt) + .map(|chunk| { + let mut word = [0u8; 8]; + word[..chunk.len()].copy_from_slice(chunk); + GoldilocksField::from_noncanonical_u64(u64::from_le_bytes(word)) + }) + .collect(); + if felts.len() < pad_to { + felts.resize(pad_to, GoldilocksField::ZERO); + } + plonky2::hash::poseidon::PoseidonHash::hash_no_pad(&felts) + .to_bytes() + .try_into() + .expect("poseidon output is 32 bytes") +} + fn hash_padded_v10(bytes: &[u8]) -> [u8; 32] { use qp_poseidon_core::Goldilocks; const PAD: usize = 189; @@ -932,6 +971,30 @@ mod tests { ); } + /// Vectors computed with the exact crates the shipped Resonance chains + /// pinned: poseidon-resonance 0.8.0 (rev fcb49a7, plonky2 fork rev 80a1000, + /// per chain tag v0.0.12-resonance-alpha) and crates.io qp-poseidon 0.9.1 + /// (per chain rev e9fc9b9). The 2592-byte input is ML-DSA-87 pubkey sized. + #[test] + fn pre_v095_dilithium_matches_original_crate_vectors() { + assert_eq!( + DilithiumHash::V08Padded.derive(&[0u8]), + hex32("fdf0715f178bfb2381d3804961bda8c679990d6318ff53f7a6475e1bef1982ca") + ); + assert_eq!( + DilithiumHash::V08Padded.derive(&[5u8; 2592]), + hex32("9c69917b10f0228a0beed1d78ce34026b4778dc04a49a401dd5e692a51f44207") + ); + assert_eq!( + DilithiumHash::V091Padded.derive(&[0u8]), + hex32("c4f1020767625056e669e3653f190b7763c6c398a45f1dc20db0d7ed32b14ff7") + ); + assert_eq!( + DilithiumHash::V091Padded.derive(&[5u8; 2592]), + hex32("8ba4f919664c796aa811f552eaff5975570d56ed6812728944f60fe7d28d3c74") + ); + } + /// Vectors computed with qp-poseidon-core 0.9.5 (git tag v0.9.5); the `[0]` /// digest was also independently reproduced from the original tagged source. #[test] @@ -956,6 +1019,18 @@ mod tests { #[test] fn dilithium_hash_schemes_diverge() { let pk = [7u8; 64]; + let addrs: Vec<_> = DilithiumHash::ALL.iter().map(|s| s.derive(&pk)).collect(); + for i in 0..addrs.len() { + for j in (i + 1)..addrs.len() { + assert_ne!( + addrs[i], + addrs[j], + "{} and {} collide", + DilithiumHash::ALL[i].id(), + DilithiumHash::ALL[j].id() + ); + } + } let a = DilithiumHash::V09Padded.derive(&pk); let b = DilithiumHash::V10Padded.derive(&pk); let c = DilithiumHash::Rate8HashBytes.derive(&pk); From f50bb8e00f4294a07565420310be68dc4f93f390 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 16:20:10 +0800 Subject: [PATCH 05/11] Scan all HD wormhole branches and rounds when matching airdrop addresses The middle HD path component is not always a change branch: the mobile app uses 0 (external) and 1 (dedicated change branch since July 2026), but 'wormhole multiround' uses it as a round counter, deriving m/44'/189189189'/0'/round'/index' with a default of 2 rounds. The scan only covered branch 0, so app change addresses and every multiround address were missed. Scan branches/rounds 0..=8 for each index, and stretch the BIP39 seed once instead of per path so the wider scan is also faster. Co-authored-by: Cursor --- src/cli/airdrop.rs | 56 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 08bbdd3..09367ba 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -12,7 +12,9 @@ use colored::Colorize; use qp_ownership_circuit::{CircuitInputs, Secret}; use qp_poseidon_core_v09 as v09; use qp_rusty_crystals_dilithium::ml_dsa_87::SecretKey; -use qp_rusty_crystals_hdwallet::{derive_wormhole_from_mnemonic, QUANTUS_WORMHOLE_CHAIN_ID}; +use qp_rusty_crystals_hdwallet::{ + generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64, QUANTUS_WORMHOLE_CHAIN_ID, +}; use qp_zk_circuits_common::utils::BytesDigest; use serde::{Deserialize, Serialize}; use sp_core::crypto::{AccountId32, Ss58Codec}; @@ -22,6 +24,10 @@ const CLAIM_CONTEXT: &[u8] = b"qp-airdrop-claim-v1"; const CLAIM_TTL_SECS: i64 = 10 * 60; const DEFAULT_SERVER: &str = "http://127.0.0.1:8080"; const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive = 0..=16; +/// Middle HD path component. The mobile app uses 0 (external) and 1 (change); +/// `wormhole multiround` uses it as a round counter (default 2 rounds), so +/// scan several rounds beyond that. +const HD_WORMHOLE_BRANCHES: std::ops::RangeInclusive = 0..=8; const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact"; #[derive(Subcommand, Debug)] @@ -48,7 +54,8 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, - /// HD wormhole index at round 0 (default: scan 0..=16) + /// HD wormhole address index, scanned across branches/rounds 0..=8 + /// (default: scan indexes 0..=16) #[arg(long)] wormhole_index: Option, }, @@ -79,7 +86,8 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, - /// HD wormhole index at round 0 (default: scan 0..=16) + /// HD wormhole address index, scanned across branches/rounds 0..=8 + /// (default: scan indexes 0..=16) #[arg(long)] wormhole_index: Option, @@ -336,12 +344,19 @@ fn derive_hd_wormhole_secrets( Some(index) => vec![index], None => HD_WORMHOLE_INDEXES.collect(), }; + // Stretch the BIP39 seed once, then walk the HD tree per path. The + // mnemonic copy passed in is zeroized by `mnemonic_to_seed`. + let mut seed = SensitiveBytes64::zeroed(); + mnemonic_to_seed(mnemonic.to_string(), None, &mut seed) + .map_err(|e| QuantusError::Generic(format!("invalid mnemonic: {e:?}")))?; let mut out = Vec::new(); - for index in indexes { - let path = format!("m/44'/{}/0'/0'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, index); - let pair = derive_wormhole_from_mnemonic(mnemonic, None, &path) - .map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?; - out.push((SpendSecret(*pair.secret().as_bytes()), format!("hd {path}"))); + for branch in HD_WORMHOLE_BRANCHES { + for &index in &indexes { + let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index); + let pair = generate_wormhole_from_seed(&seed, &path) + .map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?; + out.push((SpendSecret(*pair.secret().as_bytes()), format!("hd {path}"))); + } } Ok(out) } @@ -946,6 +961,31 @@ mod tests { hex::decode(s).unwrap().try_into().unwrap() } + #[test] + fn hd_scan_covers_change_branch_and_multiround_rounds() { + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon about"; + let secrets = derive_hd_wormhole_secrets(mnemonic, None).unwrap(); + assert_eq!(secrets.len(), 9 * 17); + + // A `wormhole multiround` round-2 address must be in the scan and match + // direct derivation. + let path = format!("m/44'/{}/0'/2'/1'", QUANTUS_WORMHOLE_CHAIN_ID); + let direct = + qp_rusty_crystals_hdwallet::derive_wormhole_from_mnemonic(mnemonic, None, &path) + .unwrap(); + let (secret, _) = secrets + .iter() + .find(|(_, label)| label == &format!("hd {path}")) + .expect("round-2 path in scan"); + assert_eq!(secret.bytes(), direct.secret().as_bytes()); + + // Explicit index still scans every branch/round. + let pinned = derive_hd_wormhole_secrets(mnemonic, Some(1)).unwrap(); + assert_eq!(pinned.len(), 9); + assert!(pinned.iter().any(|(_, label)| label == &format!("hd {path}"))); + } + #[test] fn claim_message_is_address_dest_expiry() { let address = [1u8; 32]; From cc31282389ad3d901a1b630c3386df9de02efb5e Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 16:49:46 +0800 Subject: [PATCH 06/11] Address review: heap zeroization for wormhole matching, safe developer wallet recreate - Matching no longer frees heap buffers still holding the spend secret: preimages are built in pre-sized zeroize-on-drop SensitiveFelts buffers, secret felt encodings stream through injective4_secret_words instead of temporary Vecs, the rate-4 sponge wipes its stack state, and the prover boundary uses Secret::new so the handoff copy is scrubbed. An allocator-based regression test scans every freed block for the secret in both raw-byte and felt encodings (with a documented exemption for the one buffer qp-poseidon-core 0.9.5 frees internally). - recreate_developer_wallet validates the crystal_* name and builds the full replacement before touching the existing file, then swaps it in atomically via Keystore::save_wallet; create-test-wallets now exits nonzero when any wallet fails. Co-authored-by: Cursor --- Cargo.lock | 2 + Cargo.toml | 4 + src/cli/airdrop.rs | 224 +++++++++++++++++++++++++++++++++++++++++---- src/cli/mod.rs | 9 ++ src/wallet/mod.rs | 99 +++++++++++++++++--- 5 files changed, 308 insertions(+), 30 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb2122c..01ff40a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5082,6 +5082,8 @@ dependencies = [ "libc", "minifb", "nokhwa", + "p3-field", + "p3-goldilocks", "parity-scale-codec", "qp-dilithium-crypto", "qp-human-checkphrase", diff --git a/Cargo.toml b/Cargo.toml index 8aa2870..a856c3a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,10 @@ subxt-metadata = "0.44" # ZK proof generation (aligned with chain) anyhow = "1.0" +# Field type of qp-poseidon-core 0.9.5, needed to build v09 hash preimages +# in pre-sized buffers (see SensitiveFelts in cli/airdrop.rs). +p3-field = { version = "0.3.0", default-features = false } +p3-goldilocks = { version = "0.3.0", default-features = false } qp-ownership-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } qp-ownership-prover = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } qp-plonky2 = { version = "1.5.5", default-features = false, features = ["rand", "std"] } diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 09367ba..0244aa2 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -591,7 +591,11 @@ async fn build_wormhole_claim( secret: &SpendSecret, claim_account: [u8; 32], ) -> Result { - let secret = Secret::try_from(*secret.bytes()) + // `Secret::new` zeroizes its source, so this stack copy is scrubbed even + // though it outlives the call (unlike `Secret::try_from`, which leaves + // the source bytes intact). + let mut secret_bytes = *secret.bytes(); + let secret = Secret::new(&mut secret_bytes) .map_err(|e| QuantusError::Generic(format!("invalid wormhole secret: {e:?}")))?; let claim = BytesDigest::try_from(claim_account.as_slice()) .map_err(|e| QuantusError::Generic(format!("invalid claim account: {e:?}")))?; @@ -785,25 +789,34 @@ impl WormholeHash { fn derive(self, secret: &[u8; 32]) -> DerivedWormhole { if matches!(self, Self::V09Injective) { - let core = v09::Poseidon2Core::new(); - let mut preimage = v09::injective_bytes_to_felts(b"wormhole"); - preimage.extend(v09::injective_bytes_to_felts(secret)); - let first_hash = core.hash_no_pad(preimage); - let address = core.hash_no_pad(v09::digest_bytes_to_felts(&first_hash)); - return DerivedWormhole { first_hash, address }; + return derive_v09(secret); + } + let salt = injective4(b"wormhole"); + // Full capacity up front: growing the buffer after secret felts are + // written would free the old block unscrubbed. injective4 of a + // 32-byte secret is exactly 9 felts; compact8 is 4. + let mut preimage = SensitiveFelts::with_capacity(salt.len() + 9); + for felt in salt { + preimage.push(felt); } - let mut preimage = injective4(b"wormhole"); - preimage.extend(self.encode_secret(secret)); - let first_hash = self.sponge().hash_felts(&preimage); - let address = self.sponge().rehash(&first_hash); - DerivedWormhole { first_hash, address } - } - - fn encode_secret(self, secret: &[u8; 32]) -> Vec { match self { - Self::V09Injective | Self::Rate4Injective | Self::Rate8Injective => injective4(secret), - Self::Rate4Compact | Self::Rate8Compact => compact8_decode(secret).to_vec(), + Self::V09Injective => unreachable!("handled above"), + Self::Rate4Injective | Self::Rate8Injective => { + for word in injective4_secret_words(secret) { + preimage.push(qp_poseidon_core::Goldilocks::from_u64(word)); + } + }, + Self::Rate4Compact | Self::Rate8Compact => { + let mut digest = compact8_decode(secret); + for felt in digest { + preimage.push(felt); + } + wipe_felts(&mut digest); + }, } + let first_hash = self.sponge().hash_felts(preimage.as_slice()); + let address = self.sponge().rehash(&first_hash); + DerivedWormhole { first_hash, address } } fn sponge(self) -> Sponge { @@ -840,6 +853,68 @@ impl Sponge { } } +fn derive_v09(secret: &[u8; 32]) -> DerivedWormhole { + use p3_field::integers::QuotientMap; + let core = v09::Poseidon2Core::new(); + let salt = v09::injective_bytes_to_felts(b"wormhole"); + // One pre-sized buffer so growth never frees a partial copy of the + // secret. `hash_no_pad` takes it by value and frees it unscrubbed inside + // the pinned historical crate β€” the heap-zeroization test exempts exactly + // this block, mirroring qp-zk-circuits' upstream carve-outs. + let mut preimage: Vec = Vec::with_capacity(salt.len() + 9); + preimage.extend_from_slice(&salt); + preimage.extend(injective4_secret_words(secret).map(p3_goldilocks::Goldilocks::from_int)); + let first_hash = core.hash_no_pad(preimage); + let address = core.hash_no_pad(v09::digest_bytes_to_felts(&first_hash)); + DerivedWormhole { first_hash, address } +} + +/// The injective 4-bytes-per-felt encoding of a 32-byte secret, as canonical +/// limb values: eight little-endian u32 words plus the `1` terminator +/// (32 % 4 == 0, so the terminator is always appended). Matches both +/// `injective4` and v0.9.5's `injective_bytes_to_felts` without materializing +/// an intermediate felt buffer. +fn injective4_secret_words(secret: &[u8; 32]) -> impl Iterator + '_ { + secret + .chunks(4) + .map(|chunk| u32::from_le_bytes(chunk.try_into().expect("4-byte chunk")) as u64) + .chain([1u64]) +} + +/// Heap buffer for secret-bearing field elements. The full capacity must be +/// reserved before secret material is written (a growing `Vec` frees its old +/// block unscrubbed); limbs are wiped on drop. +struct SensitiveFelts(Vec); + +impl SensitiveFelts { + fn with_capacity(capacity: usize) -> Self { + Self(Vec::with_capacity(capacity)) + } + + fn push(&mut self, felt: qp_poseidon_core::Goldilocks) { + debug_assert!(self.0.len() < self.0.capacity(), "SensitiveFelts must be pre-sized"); + self.0.push(felt); + } + + fn as_slice(&self) -> &[qp_poseidon_core::Goldilocks] { + &self.0 + } +} + +impl Drop for SensitiveFelts { + fn drop(&mut self) { + wipe_felts(&mut self.0); + } +} + +/// Zero field elements, resistant to dead-store elimination: `black_box` +/// makes the compiler assume the zeros are observed, so the fill cannot be +/// elided (same construction as qp-poseidon-core's internal state wipe). +fn wipe_felts(felts: &mut [qp_poseidon_core::Goldilocks]) { + felts.fill(qp_poseidon_core::Goldilocks::ZERO); + core::hint::black_box(felts); +} + fn compact8_decode( bytes: &[u8; 32], ) -> [qp_poseidon_core::Goldilocks; qp_poseidon_core::POSEIDON2_OUTPUT] { @@ -907,6 +982,10 @@ fn hash_felts_rate4_pad10(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] { let digest: [Goldilocks; POSEIDON2_OUTPUT] = state[..POSEIDON2_OUTPUT].try_into().expect("width > output"); + // The absorb buffer holds raw preimage felts and the state is the + // permuted secret; wipe both before returning. + wipe_felts(&mut state); + wipe_felts(&mut buf); qp_poseidon_core::serialization::digest_to_bytes(&digest) } @@ -1172,3 +1251,114 @@ mod tests { assert!(finish_claims(true, 0, 1, 0, 0).is_ok()); } } + +/// Regression test (security review): wormhole address matching must never +/// free heap memory that still contains the spend secret. +/// +/// Mirroring `heap_zeroization.rs` in qp-zk-circuits, a global allocator +/// scans every freed block for the secret at `dealloc` time (the block is +/// still valid inside the hook). Two byte images are searched, because the +/// secret appears in two encodings: the raw 32 bytes (also the in-memory +/// image of the compact8 felt encoding β€” every 8-byte limb of the ASCII +/// pattern is canonical), and the injective4 felt image (4 secret bytes then +/// 4 zero bytes per limb). +/// +/// # Known upstream exemption +/// +/// qp-poseidon-core 0.9.5's `hash_no_pad` takes its preimage `Vec` by value +/// and frees it unscrubbed; only the pinned historical crate could fix that. +/// The scanner exempts a block that byte-for-byte equals that one handoff +/// buffer and nothing else, so a leak of any buffer this crate owns still +/// fails. +/// +/// The scanner only reacts to blocks containing the distinctive pattern, so +/// unrelated tests running in the same binary cannot trip it. +#[cfg(test)] +mod heap_zeroization_tests { + use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::{ + alloc::{GlobalAlloc, Layout, System}, + sync::OnceLock, + }; + + use p3_field::PrimeField64; + + use super::{injective4_secret_words, WormholeHash}; + + /// Distinctive all-ASCII 32-byte pattern; see module docs for why ASCII + /// makes the compact8 felt image identical to the raw bytes. + const SECRET_PATTERN: [u8; 32] = *b"quantus-cli-airdrop-zeroize-pat!"; + + static SCANNING: AtomicBool = AtomicBool::new(false); + static LEAKED_BLOCK_SIZE: AtomicUsize = AtomicUsize::new(0); + /// Injective4 felt image of the pattern (precomputed: the dealloc hook + /// should not allocate). + static INJECTIVE4_IMAGE: OnceLock> = OnceLock::new(); + /// Byte image of the one buffer v0.9.5's `hash_no_pad` frees for us. + static V09_HANDOFF_BLOCK: OnceLock> = OnceLock::new(); + + fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + struct SecretScanningAllocator; + + unsafe impl GlobalAlloc for SecretScanningAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + unsafe { System.alloc(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + if SCANNING.load(Ordering::SeqCst) && layout.size() >= SECRET_PATTERN.len() { + let block = unsafe { core::slice::from_raw_parts(ptr, layout.size()) }; + let hit = contains(block, &SECRET_PATTERN) || + INJECTIVE4_IMAGE.get().is_some_and(|img| contains(block, img)); + let exempt = V09_HANDOFF_BLOCK.get().is_some_and(|b| b.as_slice() == block); + if hit && !exempt { + LEAKED_BLOCK_SIZE.store(layout.size(), Ordering::SeqCst); + } + } + unsafe { System.dealloc(ptr, layout) } + } + } + + #[global_allocator] + static ALLOCATOR: SecretScanningAllocator = SecretScanningAllocator; + + fn injective4_image() -> Vec { + injective4_secret_words(&SECRET_PATTERN) + .take(8) // the terminator limb is not secret material + .flat_map(u64::to_le_bytes) + .collect() + } + + fn expected_v09_handoff_block() -> Vec { + let salt = super::v09::injective_bytes_to_felts(b"wormhole"); + salt.iter() + .map(|f| f.as_canonical_u64()) + .chain(injective4_secret_words(&SECRET_PATTERN)) + .flat_map(u64::to_le_bytes) + .collect() + } + + #[test] + fn matching_never_frees_heap_memory_containing_the_secret() { + INJECTIVE4_IMAGE.set(injective4_image()).expect("set once"); + V09_HANDOFF_BLOCK.set(expected_v09_handoff_block()).expect("set once"); + + LEAKED_BLOCK_SIZE.store(0, Ordering::SeqCst); + SCANNING.store(true, Ordering::SeqCst); + for scheme in WormholeHash::ALL { + let derived = scheme.derive(&SECRET_PATTERN); + core::hint::black_box(derived.address); + } + SCANNING.store(false, Ordering::SeqCst); + + let leaked = LEAKED_BLOCK_SIZE.load(Ordering::SeqCst); + assert_eq!( + leaked, 0, + "a heap block of {leaked} bytes still containing the spend secret was freed \ + unscrubbed; check SensitiveFelts pre-sizing and drop in cli/airdrop.rs" + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 31acc68..318cd40 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -618,6 +618,7 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro ]; let mut created_count = 0; + let mut failed_count = 0; for (name, description) in test_wallets { log_verbose!("Creating wallet: {}", name.bright_green()); @@ -632,10 +633,18 @@ pub async fn handle_developer_command(command: DeveloperCommands) -> crate::erro }, Err(e) => { log_error!("❌ Failed to create {}: {}", name.bright_red(), e); + failed_count += 1; }, } } + if failed_count > 0 { + return Err(crate::error::QuantusError::Generic(format!( + "failed to create {failed_count} of {} test wallets", + created_count + failed_count + ))); + } + log_print!(""); log_success!("πŸŽ‰ Test wallet creation complete!"); log_success!(" Created: {} wallets", created_count.to_string().bright_green()); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 726c03d..506f91a 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -201,13 +201,43 @@ impl WalletManager { if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } + let (info, encrypted_wallet) = self.build_developer_wallet(&keystore, name)?; + keystore.save_new_wallet(&encrypted_wallet)?; + Ok(info) + } + + /// Replace a `crystal_*` developer wallet so it matches current genesis keys + /// and the empty password. Used by `quantus developer create-test-wallets`. + /// + /// The replacement is built in full before the existing file is touched β€” + /// an unknown name or failed key generation leaves any existing wallet + /// intact β€” and then swapped in atomically (temp write + rename via + /// `Keystore::save_wallet`), so no failure mode deletes a wallet without + /// installing its replacement. + pub async fn recreate_developer_wallet(&self, name: &str) -> Result { + let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; + let (info, encrypted_wallet) = self.build_developer_wallet(&keystore, name)?; + keystore.save_wallet(&encrypted_wallet)?; + Ok(info) + } - // Generate the appropriate test keypair + /// Build the wallet material for a well-known `crystal_*` genesis name. + /// Rejects any other name without touching the keystore. + fn build_developer_wallet( + &self, + keystore: &Keystore, + name: &str, + ) -> Result<(WalletInfo, keystore::EncryptedWallet)> { let resonance_pair = match name { "crystal_alice" => qp_dilithium_crypto::crystal_alice(), "crystal_bob" => qp_dilithium_crypto::dilithium_bob(), "crystal_charlie" => qp_dilithium_crypto::crystal_charlie(), - _ => return Err(WalletError::KeyGeneration.into()), + _ => + return Err(crate::error::QuantusError::Generic(format!( + "'{name}' is not a developer wallet (expected crystal_alice, crystal_bob, \ + or crystal_charlie)" + ))), }; // Genesis helpers are ML-DSA-87 only. @@ -231,24 +261,17 @@ impl WalletManager { // Empty password is intentional for crystal_* developer wallets: these are // well-known genesis test keys for local development, not custody material. - // File permissions remain owner-only (0600) via Keystore::save_new_wallet. + // File permissions remain owner-only (0600) via the keystore save paths. let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, "")?; - keystore.save_new_wallet(&encrypted_wallet)?; - Ok(WalletInfo { + let info = WalletInfo { name: name.to_string(), address, created_at: encrypted_wallet.created_at, key_type: scheme.key_type_label().to_string(), derivation_path: "m/".to_string(), - }) - } - - /// Replace a `crystal_*` developer wallet so it matches current genesis keys - /// and the empty password. Used by `quantus developer create-test-wallets`. - pub async fn recreate_developer_wallet(&self, name: &str) -> Result { - let _ = self.delete_wallet(name)?; - self.create_developer_wallet(name).await + }; + Ok((info, encrypted_wallet)) } /// Export a wallet's mnemonic phrase @@ -1001,6 +1024,56 @@ mod tests { )); } + /// Regression (review): `recreate_developer_wallet` used to delete first + /// and validate later, so an unknown name destroyed a real wallet and + /// returned an error. Validation must come before any file is touched. + #[tokio::test] + async fn recreate_developer_wallet_rejects_unknown_name_and_preserves_wallet() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + wallet_manager + .create_wallet("my_wallet", Some("password123")) + .await + .expect("create user wallet"); + + let result = wallet_manager.recreate_developer_wallet("my_wallet").await; + assert!(result.is_err(), "non-developer name must be rejected"); + + wallet_manager + .load_wallet("my_wallet", "password123") + .expect("original wallet must survive a rejected recreate"); + } + + #[tokio::test] + async fn recreate_developer_wallet_replaces_existing_in_place() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let first = wallet_manager + .create_developer_wallet("crystal_alice") + .await + .expect("create developer wallet"); + + let second = wallet_manager + .recreate_developer_wallet("crystal_alice") + .await + .expect("recreate developer wallet"); + // Genesis keys are deterministic, so the replacement matches. + assert_eq!(second.address, first.address); + wallet_manager + .load_wallet("crystal_alice", "") + .expect("replaced wallet must be usable"); + } + + #[tokio::test] + async fn recreate_developer_wallet_creates_when_missing() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + wallet_manager + .recreate_developer_wallet("crystal_bob") + .await + .expect("recreate must work with no existing wallet"); + wallet_manager + .load_wallet("crystal_bob", "") + .expect("created wallet must be usable"); + } + #[tokio::test] #[cfg(unix)] async fn developer_wallet_empty_password_is_intentional_and_owner_only() { From 3edf692f1ab6f92cf8ccb83ec6997b8bda1ab2a3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 17:08:20 +0800 Subject: [PATCH 07/11] Use published qp-zk-circuits 4.4.0 crates instead of git tag All ownership and wormhole crates are now on crates.io at 4.4.0 (same code as the v4.4.0 tag), exact-pinned. Co-authored-by: Cursor --- Cargo.lock | 30 ++++++++++++++++++++---------- Cargo.toml | 20 ++++++++++---------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 01ff40a..58a1380 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4798,7 +4798,8 @@ dependencies = [ [[package]] name = "qp-ownership-circuit" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6dfd3d8f8fba2a5ebc966bb7d53f8eaf0e4582eb58663a549f9a8beb745dbb4" dependencies = [ "anyhow", "qp-ownership-inputs", @@ -4810,7 +4811,8 @@ dependencies = [ [[package]] name = "qp-ownership-inputs" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d8b25b2a264b401240b011f6748306191478122b7b1fc15e45aad735ed05510" dependencies = [ "anyhow", "qp-wormhole-inputs", @@ -4819,7 +4821,8 @@ dependencies = [ [[package]] name = "qp-ownership-prover" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04ea262e2d923cb1314cfe32b6289da3b3afe3f284e8a749a6ea5eafded3b50b" dependencies = [ "anyhow", "qp-ownership-circuit", @@ -4970,7 +4973,8 @@ dependencies = [ [[package]] name = "qp-wormhole-aggregator" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f32f9fbbd49a0655b267a151afe4e6fd38edcad13bce79e487a949073672d46" dependencies = [ "anyhow", "hex", @@ -4988,7 +4992,8 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997bce0db5f628ff973b9983b167f8499ab5e0aa2b07848d97d6be5e7b0e921a" dependencies = [ "anyhow", "hex", @@ -5001,7 +5006,8 @@ dependencies = [ [[package]] name = "qp-wormhole-circuit-builder" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d30af09b9b9fcde24549d40749219ecf4098a48ef7c0cae2cc414141b4435" dependencies = [ "anyhow", "clap", @@ -5015,7 +5021,8 @@ dependencies = [ [[package]] name = "qp-wormhole-inputs" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536c7358f87bb209d3113e89763a1bc7a45a2cd4fc82968869f1be3cdd59a21a" dependencies = [ "anyhow", ] @@ -5023,7 +5030,8 @@ dependencies = [ [[package]] name = "qp-wormhole-prover" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1fd5f90cfca2e5f01d7e36fe35d417b541443d7e6548d99b7e73b0c3ce75c4" dependencies = [ "anyhow", "qp-plonky2", @@ -5034,7 +5042,8 @@ dependencies = [ [[package]] name = "qp-wormhole-verifier" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019abd6bf19f3340227acf7d5129e3f5091f30edcbc5e51601e531456122309a" dependencies = [ "anyhow", "qp-plonky2-verifier", @@ -5045,7 +5054,8 @@ dependencies = [ [[package]] name = "qp-zk-circuits-common" version = "4.4.0" -source = "git+https://github.com/Quantus-Network/qp-zk-circuits?tag=v4.4.0#c2ceefacd28fc800f0f1a2e4236d5d695f4239d2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10135f3f253f33423341f97c71de9eeaf39fe7f1d791e1cfab026c14fdf172bd" dependencies = [ "anyhow", "qp-plonky2", diff --git a/Cargo.toml b/Cargo.toml index a856c3a..417f2b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,20 +126,20 @@ anyhow = "1.0" # in pre-sized buffers (see SensitiveFelts in cli/airdrop.rs). p3-field = { version = "0.3.0", default-features = false } p3-goldilocks = { version = "0.3.0", default-features = false } -qp-ownership-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } -qp-ownership-prover = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } +qp-ownership-circuit = { version = "=4.4.0", default-features = false, features = ["std"] } +qp-ownership-prover = { version = "=4.4.0", default-features = false, features = ["std"] } qp-plonky2 = { version = "1.5.5", default-features = false, features = ["rand", "std"] } qp-plonky2-verifier = { version = "1.5.5", default-features = false } qp-poseidon-core = "3.1.0" # Resonance-era (v0.9.x) permutation constants differ from the current ones. qp-poseidon-core-v09 = { package = "qp-poseidon-core", version = "=0.9.5" } -qp-wormhole-aggregator = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["rayon", "std"] } -qp-wormhole-circuit = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } -qp-wormhole-circuit-builder = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0" } -qp-wormhole-inputs = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } -qp-wormhole-prover = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } -qp-wormhole-verifier = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } -qp-zk-circuits-common = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0", default-features = false, features = ["std"] } +qp-wormhole-aggregator = { version = "=4.4.0", default-features = false, features = ["rayon", "std"] } +qp-wormhole-circuit = { version = "=4.4.0", default-features = false, features = ["std"] } +qp-wormhole-circuit-builder = "=4.4.0" +qp-wormhole-inputs = { version = "=4.4.0", default-features = false, features = ["std"] } +qp-wormhole-prover = { version = "=4.4.0", default-features = false, features = ["std"] } +qp-wormhole-verifier = { version = "=4.4.0", default-features = false, features = ["std"] } +qp-zk-circuits-common = { version = "=4.4.0", default-features = false, features = ["std"] } [target.'cfg(unix)'.dependencies] libc = "0.2" @@ -147,7 +147,7 @@ libc = "0.2" [build-dependencies] hex = "0.4" qp-poseidon-core = "3.1.0" -qp-wormhole-circuit-builder = { version = "4.4.0", git = "https://github.com/Quantus-Network/qp-zk-circuits", tag = "v4.4.0" } +qp-wormhole-circuit-builder = "=4.4.0" sha2 = "0.10" [dev-dependencies] From 945992c4e48660ec66eb2150143bd65a62be3752 Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 17:21:04 +0800 Subject: [PATCH 08/11] Hash v09 preimages locally so no secret-bearing allocation is ever freed qp-poseidon-core 0.9.5's hash_no_pad consumes its preimage Vec and frees it unscrubbed, so the previous commit exempted that one block in the heap-zeroization test. Rebuild the v0.9.5 sponge from the same public crates (Poseidon2Goldilocks<12>, ChaCha8 constants, seed 0x189189189189189) and hash the secret preimage from a borrowed zeroize-on-drop buffer with wiped stack state. The allocator regression test now requires zero leaked blocks with no exemptions; golden vectors pin equality with the historical hasher. Co-authored-by: Cursor --- Cargo.lock | 2 + Cargo.toml | 7 +- src/cli/airdrop.rs | 157 +++++++++++++++++++++++++++++++-------------- 3 files changed, 115 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58a1380..6295e18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5094,6 +5094,7 @@ dependencies = [ "nokhwa", "p3-field", "p3-goldilocks", + "p3-symmetric", "parity-scale-codec", "qp-dilithium-crypto", "qp-human-checkphrase", @@ -5116,6 +5117,7 @@ dependencies = [ "quantus_ur", "quinn-proto", "rand 0.9.5", + "rand_chacha 0.9.0", "reqwest 0.12.28", "rpassword", "rustls-webpki", diff --git a/Cargo.toml b/Cargo.toml index 417f2b8..67c4217 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,15 +122,18 @@ subxt-metadata = "0.44" # ZK proof generation (aligned with chain) anyhow = "1.0" -# Field type of qp-poseidon-core 0.9.5, needed to build v09 hash preimages -# in pre-sized buffers (see SensitiveFelts in cli/airdrop.rs). +# Building blocks of qp-poseidon-core 0.9.5's hasher. The local v09 sponge in +# cli/airdrop.rs hashes the secret preimage from a borrowed slice because the +# 0.9.5 crate's hash_no_pad consumes its Vec and frees it unscrubbed. p3-field = { version = "0.3.0", default-features = false } p3-goldilocks = { version = "0.3.0", default-features = false } +p3-symmetric = { version = "0.3.0", default-features = false } qp-ownership-circuit = { version = "=4.4.0", default-features = false, features = ["std"] } qp-ownership-prover = { version = "=4.4.0", default-features = false, features = ["std"] } qp-plonky2 = { version = "1.5.5", default-features = false, features = ["rand", "std"] } qp-plonky2-verifier = { version = "1.5.5", default-features = false } qp-poseidon-core = "3.1.0" +rand_chacha = { version = "0.9", default-features = false } # Resonance-era (v0.9.x) permutation constants differ from the current ones. qp-poseidon-core-v09 = { package = "qp-poseidon-core", version = "=0.9.5" } qp-wormhole-aggregator = { version = "=4.4.0", default-features = false, features = ["rayon", "std"] } diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 0244aa2..7065aea 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -795,7 +795,8 @@ impl WormholeHash { // Full capacity up front: growing the buffer after secret felts are // written would free the old block unscrubbed. injective4 of a // 32-byte secret is exactly 9 felts; compact8 is 4. - let mut preimage = SensitiveFelts::with_capacity(salt.len() + 9); + let mut preimage = + SensitiveFelts::with_capacity(qp_poseidon_core::Goldilocks::ZERO, salt.len() + 9); for felt in salt { preimage.push(felt); } @@ -854,21 +855,94 @@ impl Sponge { } fn derive_v09(secret: &[u8; 32]) -> DerivedWormhole { - use p3_field::integers::QuotientMap; - let core = v09::Poseidon2Core::new(); + use p3_field::{integers::QuotientMap, PrimeCharacteristicRing}; + type F = p3_goldilocks::Goldilocks; let salt = v09::injective_bytes_to_felts(b"wormhole"); - // One pre-sized buffer so growth never frees a partial copy of the - // secret. `hash_no_pad` takes it by value and frees it unscrubbed inside - // the pinned historical crate β€” the heap-zeroization test exempts exactly - // this block, mirroring qp-zk-circuits' upstream carve-outs. - let mut preimage: Vec = Vec::with_capacity(salt.len() + 9); - preimage.extend_from_slice(&salt); - preimage.extend(injective4_secret_words(secret).map(p3_goldilocks::Goldilocks::from_int)); - let first_hash = core.hash_no_pad(preimage); - let address = core.hash_no_pad(v09::digest_bytes_to_felts(&first_hash)); + // Pre-sized zeroize-on-drop buffer, hashed from a borrowed slice by the + // local v09 sponge. qp-poseidon-core 0.9.5's own `hash_no_pad` takes its + // preimage Vec by value and frees it unscrubbed, so it must never see + // the secret. + let mut preimage = SensitiveFelts::with_capacity(F::ZERO, salt.len() + 9); + for felt in salt { + preimage.push(felt); + } + for word in injective4_secret_words(secret) { + preimage.push(F::from_int(word)); + } + let first_hash = v09_hash_no_pad(preimage.as_slice()); + let address = v09_hash_no_pad(&v09::digest_bytes_to_felts(&first_hash)); DerivedWormhole { first_hash, address } } +/// The v0.9.5 Poseidon2 permutation, rebuilt from the same public crates the +/// historical qp-poseidon-core used: ChaCha8-derived constants with seed +/// 0x189189189189189 over `Poseidon2Goldilocks<12>`. Equality with +/// `Poseidon2Core::new()` is pinned by the wormhole-v09-injective golden +/// vector test. +fn v09_permutation() -> &'static p3_goldilocks::Poseidon2Goldilocks<12> { + use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng}; + static PERMUTATION: std::sync::OnceLock> = + std::sync::OnceLock::new(); + PERMUTATION.get_or_init(|| { + const V09_POSEIDON2_SEED: u64 = 0x189189189189189; + let mut rng = ChaCha8Rng::seed_from_u64(V09_POSEIDON2_SEED); + p3_goldilocks::Poseidon2Goldilocks::<12>::new_from_rng_128(&mut rng) + }) +} + +/// qp-poseidon-core 0.9.5's `hash_no_pad` sponge (rate 4, terminator felt in +/// the last short block, `[1,0,0,0]` block after a full final chunk, then an +/// unconditional `[0,0,0,1]` domain block), reimplemented over a borrowed +/// slice with stack state so no secret-bearing heap allocation is created or +/// handed to code that frees it unscrubbed. +fn v09_hash_no_pad(input: &[p3_goldilocks::Goldilocks]) -> [u8; 32] { + use p3_field::{PrimeCharacteristicRing, PrimeField64}; + use p3_symmetric::Permutation; + type F = p3_goldilocks::Goldilocks; + const WIDTH: usize = 12; + const RATE: usize = 4; + + let wipe = |felts: &mut [F]| { + felts.fill(F::ZERO); + core::hint::black_box(felts); + }; + + let poseidon2 = v09_permutation(); + let mut state = [F::ZERO; WIDTH]; + let mut block = [F::ZERO; RATE]; + let num_chunks = input.chunks(RATE).len(); + let mut unpadded = false; + for (j, chunk) in input.chunks(RATE).enumerate() { + block.fill(F::ZERO); + if j == num_chunks - 1 { + if chunk.len() < RATE { + block[chunk.len()] = F::ONE; + } else { + unpadded = true; + } + } + block[..chunk.len()].copy_from_slice(chunk); + for i in 0..RATE { + state[i] += block[i]; + } + poseidon2.permute_mut(&mut state); + } + if unpadded { + state[0] += F::ONE; + poseidon2.permute_mut(&mut state); + } + state[RATE - 1] += F::ONE; + poseidon2.permute_mut(&mut state); + + let mut out = [0u8; 32]; + for (i, felt) in state[..RATE].iter().enumerate() { + out[i * 8..(i + 1) * 8].copy_from_slice(&felt.as_canonical_u64().to_le_bytes()); + } + wipe(&mut state); + wipe(&mut block); + out +} + /// The injective 4-bytes-per-felt encoding of a 32-byte secret, as canonical /// limb values: eight little-endian u32 words plus the `1` terminator /// (32 % 4 == 0, so the terminator is always appended). Matches both @@ -883,27 +957,33 @@ fn injective4_secret_words(secret: &[u8; 32]) -> impl Iterator + '_ /// Heap buffer for secret-bearing field elements. The full capacity must be /// reserved before secret material is written (a growing `Vec` frees its old -/// block unscrubbed); limbs are wiped on drop. -struct SensitiveFelts(Vec); +/// block unscrubbed); limbs are wiped on drop. Generic so both the current +/// qp-poseidon-core felts and the v09 p3-goldilocks felts are covered. +struct SensitiveFelts { + felts: Vec, + zero: F, +} -impl SensitiveFelts { - fn with_capacity(capacity: usize) -> Self { - Self(Vec::with_capacity(capacity)) +impl SensitiveFelts { + fn with_capacity(zero: F, capacity: usize) -> Self { + Self { felts: Vec::with_capacity(capacity), zero } } - fn push(&mut self, felt: qp_poseidon_core::Goldilocks) { - debug_assert!(self.0.len() < self.0.capacity(), "SensitiveFelts must be pre-sized"); - self.0.push(felt); + fn push(&mut self, felt: F) { + debug_assert!(self.felts.len() < self.felts.capacity(), "SensitiveFelts must be pre-sized"); + self.felts.push(felt); } - fn as_slice(&self) -> &[qp_poseidon_core::Goldilocks] { - &self.0 + fn as_slice(&self) -> &[F] { + &self.felts } } -impl Drop for SensitiveFelts { +impl Drop for SensitiveFelts { fn drop(&mut self) { - wipe_felts(&mut self.0); + // Same dead-store-resistant wipe as `wipe_felts`. + self.felts.fill(self.zero); + core::hint::black_box(self.felts.as_mut_slice()); } } @@ -1261,15 +1341,9 @@ mod tests { /// secret appears in two encodings: the raw 32 bytes (also the in-memory /// image of the compact8 felt encoding β€” every 8-byte limb of the ASCII /// pattern is canonical), and the injective4 felt image (4 secret bytes then -/// 4 zero bytes per limb). -/// -/// # Known upstream exemption -/// -/// qp-poseidon-core 0.9.5's `hash_no_pad` takes its preimage `Vec` by value -/// and frees it unscrubbed; only the pinned historical crate could fix that. -/// The scanner exempts a block that byte-for-byte equals that one handoff -/// buffer and nothing else, so a leak of any buffer this crate owns still -/// fails. +/// 4 zero bytes per limb). No exemptions: the v09 scheme hashes through the +/// local borrowed-slice sponge precisely so that no allocation holding the +/// secret is ever freed, by anyone. /// /// The scanner only reacts to blocks containing the distinctive pattern, so /// unrelated tests running in the same binary cannot trip it. @@ -1281,8 +1355,6 @@ mod heap_zeroization_tests { sync::OnceLock, }; - use p3_field::PrimeField64; - use super::{injective4_secret_words, WormholeHash}; /// Distinctive all-ASCII 32-byte pattern; see module docs for why ASCII @@ -1294,8 +1366,6 @@ mod heap_zeroization_tests { /// Injective4 felt image of the pattern (precomputed: the dealloc hook /// should not allocate). static INJECTIVE4_IMAGE: OnceLock> = OnceLock::new(); - /// Byte image of the one buffer v0.9.5's `hash_no_pad` frees for us. - static V09_HANDOFF_BLOCK: OnceLock> = OnceLock::new(); fn contains(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) @@ -1313,8 +1383,7 @@ mod heap_zeroization_tests { let block = unsafe { core::slice::from_raw_parts(ptr, layout.size()) }; let hit = contains(block, &SECRET_PATTERN) || INJECTIVE4_IMAGE.get().is_some_and(|img| contains(block, img)); - let exempt = V09_HANDOFF_BLOCK.get().is_some_and(|b| b.as_slice() == block); - if hit && !exempt { + if hit { LEAKED_BLOCK_SIZE.store(layout.size(), Ordering::SeqCst); } } @@ -1332,19 +1401,9 @@ mod heap_zeroization_tests { .collect() } - fn expected_v09_handoff_block() -> Vec { - let salt = super::v09::injective_bytes_to_felts(b"wormhole"); - salt.iter() - .map(|f| f.as_canonical_u64()) - .chain(injective4_secret_words(&SECRET_PATTERN)) - .flat_map(u64::to_le_bytes) - .collect() - } - #[test] fn matching_never_frees_heap_memory_containing_the_secret() { INJECTIVE4_IMAGE.set(injective4_image()).expect("set once"); - V09_HANDOFF_BLOCK.set(expected_v09_handoff_block()).expect("set once"); LEAKED_BLOCK_SIZE.store(0, Ordering::SeqCst); SCANNING.store(true, Ordering::SeqCst); From 8cbd49c649a2c4cb7a2262d87c9dfc19f5c4240e Mon Sep 17 00:00:00 2001 From: illuzen Date: Mon, 14 Sep 2026 18:28:13 +0800 Subject: [PATCH 09/11] Airdrop: match Dilithium keys from every historical keygen era, wormhole from Bitcoin-seed tree Mirrors the quantus-apps SDK change. The ML-DSA-87 public key for the same mnemonic changed four times (pre-FIPS SHAKE256(seed[..32]) expansion; FIPS 204 whole-seed expansion; hardened CLI paths; "Dilithium seed" BIP32 master), so find_matches now derives candidate keypairs for every era from the wallet seed and hashes each under every address scheme. Wormhole HD scanning covers the pre-2.1.0 "Bitcoin seed" tree and the legacy master-node secret as well. Both historical seed expansions are rebuilt locally from the current dilithium crate's public primitives in wipeable buffers (the historical crates' keygens free seed-bearing heap copies unscrubbed). Claims for historical matches re-derive the era's key from the stored self-zeroizing seed and sign with the current crate. Golden vectors pin pk/sk equality with the shipped dilithium 1.0.3 / 2.0.0 and hdwallet 1.0.0 crates. Co-authored-by: Cursor --- Cargo.lock | 15 ++ Cargo.toml | 5 + src/cli/airdrop.rs | 469 +++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 454 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6295e18..071db2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3333,6 +3333,7 @@ dependencies = [ "once_cell", "serdect", "sha2 0.10.9", + "signature", ] [[package]] @@ -3773,6 +3774,19 @@ dependencies = [ "serde", ] +[[package]] +name = "nam-tiny-hderive" +version = "0.3.1-nam.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2cd44792ed5cd84dc9dedc3d572242ac00e76c244e85eb4bf34da2c6239ce30" +dependencies = [ + "base58", + "hmac 0.12.1", + "k256", + "sha2 0.10.9", + "zeroize", +] + [[package]] name = "nanorand" version = "0.7.0" @@ -5091,6 +5105,7 @@ dependencies = [ "jsonrpsee", "libc", "minifb", + "nam-tiny-hderive", "nokhwa", "p3-field", "p3-goldilocks", diff --git a/Cargo.toml b/Cargo.toml index 67c4217..bbd4f80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,11 @@ qp-dilithium-crypto = { version = "0.6.1", features = ["serde"] } qp-human-checkphrase = "2.2.1" qp-rusty-crystals-dilithium = { version = "4.1.1", features = ["ml-dsa-65", "ml-dsa-87"] } qp-rusty-crystals-hdwallet = { version = "4.1.1", features = ["ml-dsa-65"] } +# The pre-March-2026 BIP32 tree ("Bitcoin seed" master HMAC key, soft child +# support): historical Dilithium accounts and wormhole entropies in +# cli/airdrop.rs derive through it. Same crate qp-rusty-crystals-hdwallet +# < 2.1.0 used internally. +nam-tiny-hderive = { version = "=0.3.1-nam.1", default-features = false } # HTTP client for Subsquid queries blake3 = "1.8" diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 7065aea..653b642 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -9,9 +9,10 @@ use crate::{ }; use clap::Subcommand; use colored::Colorize; +use nam_tiny_hderive::bip32::ExtendedPrivKey; use qp_ownership_circuit::{CircuitInputs, Secret}; use qp_poseidon_core_v09 as v09; -use qp_rusty_crystals_dilithium::ml_dsa_87::SecretKey; +use qp_rusty_crystals_dilithium::{fips202, ml_dsa_87::SecretKey, packing, params, poly, polyvec}; use qp_rusty_crystals_hdwallet::{ generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64, QUANTUS_WORMHOLE_CHAIN_ID, }; @@ -29,6 +30,10 @@ const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive = 0..=16; /// scan several rounds beyond that. const HD_WORMHOLE_BRANCHES: std::ops::RangeInclusive = 0..=8; const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact"; +/// BIP44 coin type for Dilithium keys (the wormhole coin type is 189189189'). +const DILITHIUM_CHAIN_ID: &str = "189189'"; +/// Account indexes scanned per historical Dilithium keygen family. +const DILITHIUM_SCAN_ACCOUNTS: u32 = 9; #[derive(Subcommand, Debug)] pub enum AirdropCommands { @@ -160,7 +165,7 @@ async fn handle_check( return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); } - let matches = find_matches(&snapshot, &credentials); + let matches = find_matches(&snapshot, &credentials)?; print_snapshot_header(&snapshot); print_matches(&matches); Ok(()) @@ -189,7 +194,7 @@ async fn handle_claim( let claim_account = resolve_claim_account(to.as_deref(), &credentials)?; let snapshot = fetch_snapshot(&server).await?; - let matches = find_matches(&snapshot, &credentials); + let matches = find_matches(&snapshot, &credentials)?; print_snapshot_header(&snapshot); print_matches(&matches); @@ -279,6 +284,10 @@ struct Credentials { /// reopens the wallet (which would lose `--password-file`). wallet_account: Option<[u8; 32]>, wormhole_secrets: Vec<(SpendSecret, String)>, + /// Stretched BIP39 seed (self-zeroizing), kept so historical Dilithium + /// keypairs can be re-derived at claim time instead of holding every + /// candidate secret key in memory. + hd_seed: Option, } fn collect_credentials( @@ -291,6 +300,7 @@ fn collect_credentials( let mut wormhole_secrets = Vec::new(); let mut dilithium = None; let mut wallet_account = None; + let mut hd_seed = None; if let Some(name) = wallet { let (keypair, mnemonic) = load_wallet_material(name, password, password_file)?; @@ -304,12 +314,16 @@ fn collect_credentials( } else { dilithium = Some(keypair); } - if let Some(mut mnemonic) = mnemonic { - let derived = derive_hd_wormhole_secrets(&mnemonic, wormhole_index); - crate::wallet::keystore::zeroize_string(&mut mnemonic); - wormhole_secrets.extend(derived?); + if let Some(mnemonic) = mnemonic { + // Stretch the BIP39 seed once; `mnemonic_to_seed` consumes and + // zeroizes the mnemonic string. + let mut seed = SensitiveBytes64::zeroed(); + mnemonic_to_seed(mnemonic, None, &mut seed) + .map_err(|e| QuantusError::Generic(format!("invalid mnemonic: {e:?}")))?; + wormhole_secrets.extend(derive_hd_wormhole_secrets(&seed, wormhole_index)?); + hd_seed = Some(seed); } else { - log_verbose!("Wallet '{}' has no mnemonic; HD wormhole derivation skipped", name); + log_verbose!("Wallet '{}' has no mnemonic; HD derivation skipped", name); } } @@ -318,7 +332,7 @@ fn collect_credentials( wormhole_secrets.push((secret, path.display().to_string())); } - Ok(Credentials { dilithium, wallet_account, wormhole_secrets }) + Ok(Credentials { dilithium, wallet_account, wormhole_secrets, hd_seed }) } fn load_wallet_material( @@ -337,30 +351,225 @@ fn load_wallet_material( } fn derive_hd_wormhole_secrets( - mnemonic: &str, + seed: &SensitiveBytes64, wormhole_index: Option, ) -> Result> { let indexes: Vec = match wormhole_index { Some(index) => vec![index], None => HD_WORMHOLE_INDEXES.collect(), }; - // Stretch the BIP39 seed once, then walk the HD tree per path. The - // mnemonic copy passed in is zeroized by `mnemonic_to_seed`. - let mut seed = SensitiveBytes64::zeroed(); - mnemonic_to_seed(mnemonic.to_string(), None, &mut seed) - .map_err(|e| QuantusError::Generic(format!("invalid mnemonic: {e:?}")))?; let mut out = Vec::new(); + // Current "Dilithium seed" tree. for branch in HD_WORMHOLE_BRANCHES { for &index in &indexes { let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index); - let pair = generate_wormhole_from_seed(&seed, &path) + let pair = generate_wormhole_from_seed(seed, &path) .map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?; out.push((SpendSecret(*pair.secret().as_bytes()), format!("hd {path}"))); } } + // Pre-March-2026 "Bitcoin seed" tree: same paths, different master HMAC + // key, so every entropy differs. The app's first wormhole address also + // used the master node's own key (hdwallet 1.0.0 `generate_wormhole_pair`), + // hence path "m". + let mut legacy_paths = vec!["m".to_string()]; + for branch in HD_WORMHOLE_BRANCHES { + for &index in &indexes { + legacy_paths + .push(format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index)); + } + } + for path in legacy_paths { + let entropy = ExtendedPrivKey::derive(seed.as_bytes(), path.as_str()) + .map_err(|e| { + QuantusError::Generic(format!("BIP32 derivation failed at {path}: {e:?}")) + })? + .secret(); + out.push((SpendSecret(entropy), format!("bitcoin-seed {path}"))); + } Ok(out) } +// --------------------------------------------------------------------------- +// Historical ML-DSA-87 keygens +// --------------------------------------------------------------------------- +// +// The address-hash schemes below cover how a *public key* became an +// AccountId. The public key produced from the same mnemonic changed too: +// +// 1. pre Nov 2025 (qp-rusty-crystals-dilithium < 2.0.0): keygen expanded SHAKE256(seed[..32]) with +// no domain separation. Wallets were non-HD (`Keypair::generate(seed64)`, which absorbed only +// the first 32 bytes) or, after the multiple-accounts feature, HD at m/44'/189189'/N'/0/0 under +// the BIP32 master HMAC key "Bitcoin seed" with a soft tail. +// 2. Nov 2025 (dilithium 2.0.0): FIPS 204 keygen, SHAKE256(seed β€– K β€– L) absorbing the whole input. +// Same "Bitcoin seed" paths; the non-HD account went from an effective 32-byte input to the full +// 64-byte seed. +// 3. Feb 2026 (this CLI): default path hardened to m/44'/189189'/0'/0'/0'; --no-derivation wallets +// became the BIP32 child at m/44'/189189'/0'. +// 4. Mar 2026 (hdwallet 2.1.0): the BIP32 master HMAC key became "Dilithium seed" (hardened-only), +// changing every HD key again; the non-HD legacy account became FIPS keygen over seed64[..32]. +// This is the current scheme. +// +// Matching therefore derives candidate keypairs for every era and hashes +// each candidate public key under every address scheme. + +/// Secret-key bytes that wipe themselves on drop. +struct SecretKeyBytes(Vec); + +impl Drop for SecretKeyBytes { + fn drop(&mut self) { + crate::wallet::keystore::zeroize_bytes(&mut self.0); + } +} + +struct HistoricalKeypair { + public: Vec, + secret: SecretKeyBytes, +} + +/// Every mnemonicβ†’ML-DSA-87-keypair scheme a snapshot key may have used. +/// Ids are parsed by [`derive_historical_dilithium`]: `v1`/`fips` selects the +/// seed expansion, and the source is `seed` (the 64-byte BIP39 seed), +/// `seed32` (its first half, the chain's `from_seed`), `bip32:` +/// ("Bitcoin seed" BIP32 entropy), or `hd:` (current "Dilithium seed" +/// derivation). +fn dilithium_keygen_ids() -> Vec { + let mut ids = vec![ + // Era 1 non-HD (absorbs seed64[..32]); era 2 non-HD; era 4 legacy. + "v1:seed".to_string(), + "fips:seed".to_string(), + "fips:seed32".to_string(), + ]; + for account in 0..DILITHIUM_SCAN_ACCOUNTS { + // Era 1 and era 2 app/CLI accounts (soft tail), era 3 CLI hardened + // default and --no-derivation account child, era 4 current HD. + ids.push(format!("v1:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0/0")); + ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0/0")); + ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0'/0'")); + ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'")); + ids.push(format!("fips:hd:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0'/0'")); + } + ids +} + +/// Derive the ML-DSA-87 keypair for a historical keygen id from the BIP39 +/// seed. The secret key comes back in a self-wiping buffer; intermediate +/// entropy buffers are wiped before returning. +fn derive_historical_dilithium(seed: &SensitiveBytes64, id: &str) -> Result { + let (expansion, source) = id + .split_once(':') + .ok_or_else(|| QuantusError::Generic(format!("malformed keygen id {id:?}")))?; + let v1 = match expansion { + "v1" => true, + "fips" => false, + _ => return Err(QuantusError::Generic(format!("unknown keygen era in {id:?}"))), + }; + if source == "seed" { + return Ok(mldsa87_keypair(seed.as_bytes(), v1)); + } + if source == "seed32" { + return Ok(mldsa87_keypair(&seed.as_bytes()[..32], v1)); + } + if let Some(path) = source.strip_prefix("bip32:") { + let mut entropy = ExtendedPrivKey::derive(seed.as_bytes(), path) + .map_err(|e| { + QuantusError::Generic(format!("BIP32 derivation failed at {path}: {e:?}")) + })? + .secret(); + let keypair = mldsa87_keypair(&entropy, v1); + crate::wallet::keystore::zeroize_bytes(&mut entropy); + return Ok(keypair); + } + if let Some(path) = source.strip_prefix("hd:") { + if v1 { + return Err(QuantusError::Generic(format!( + "keygen id {id:?} is inconsistent: no v1-era wallet used the Dilithium-seed tree" + ))); + } + let keypair = qp_rusty_crystals_hdwallet::ml_dsa_87::derive_key_from_seed(seed, path) + .map_err(|e| QuantusError::Generic(format!("HD derivation failed at {path}: {e:?}")))?; + // `to_bytes` returns a `Zeroizing` buffer, wiped when it drops here. + let secret = SecretKeyBytes(keypair.secret().to_bytes().to_vec()); + return Ok(HistoricalKeypair { public: keypair.public().to_bytes().to_vec(), secret }); + } + Err(QuantusError::Generic(format!("unknown keygen source in {id:?}"))) +} + +/// ML-DSA-87 key generation with a selectable seed expansion, built from the +/// current crate's public primitives (its `keypair_var` is not public, and +/// the historical crates' own keygens copy the seed into heap buffers they +/// free unscrubbed). `v1` selects the pre-FIPS expansion β€” SHAKE256 over the +/// first 32 seed bytes with no `K β€– L` domain suffix; otherwise the FIPS 204 +/// expansion absorbs the whole seed plus the suffix. Byte-equality of both +/// keypairs with the shipped dilithium 1.0.3 / 2.0.0 keygens is pinned by +/// golden vectors in the tests. +fn mldsa87_keypair(seed: &[u8], v1: bool) -> HistoricalKeypair { + use crate::wallet::keystore::zeroize_bytes; + use params::{ + ml_dsa_87::{ETA, K, L, PUBLICKEYBYTES, SECRETKEYBYTES}, + CRHBYTES, SEEDBYTES, TR_BYTES, + }; + use polyvec::Polyvec; + + debug_assert!(seed.len() == 32 || seed.len() == 64); + let mut seedbuf = [0u8; 2 * SEEDBYTES + CRHBYTES]; + if v1 { + // The v1 expansion reads exactly SEEDBYTES from its input. + fips202::shake256(&mut seedbuf, &seed[..SEEDBYTES]); + } else { + let mut preimage = [0u8; 64 + 2]; + preimage[..seed.len()].copy_from_slice(seed); + preimage[seed.len()] = K as u8; + preimage[seed.len() + 1] = L as u8; + fips202::shake256(&mut seedbuf, &preimage[..seed.len() + 2]); + zeroize_bytes(&mut preimage); + } + + let mut rho = [0u8; SEEDBYTES]; + rho.copy_from_slice(&seedbuf[..SEEDBYTES]); + let mut rhoprime = [0u8; CRHBYTES]; + rhoprime.copy_from_slice(&seedbuf[SEEDBYTES..SEEDBYTES + CRHBYTES]); + let mut key = [0u8; SEEDBYTES]; + key.copy_from_slice(&seedbuf[SEEDBYTES + CRHBYTES..]); + zeroize_bytes(&mut seedbuf); + + let mut s1 = Polyvec::::default(); + for (i, p) in s1.vec.iter_mut().enumerate() { + poly::uniform_eta::(p, &rhoprime, i as u16); + } + let mut s2 = Polyvec::::default(); + for (i, p) in s2.vec.iter_mut().enumerate() { + poly::uniform_eta::(p, &rhoprime, (L + i) as u16); + } + zeroize_bytes(&mut rhoprime); + + let mut s1hat = s1.clone(); + polyvec::ntt(&mut s1hat); + let mut t1 = Polyvec::::default(); + polyvec::matrix_pointwise_montgomery_streamed(&mut t1, &rho, &s1hat); + polyvec::reduce(&mut t1); + polyvec::invntt_tomont(&mut t1); + polyvec::add(&mut t1, &s2); + polyvec::caddq(&mut t1); + let mut t0 = Polyvec::::default(); + polyvec::power2round(&mut t1, &mut t0); + + let mut pk = [0u8; PUBLICKEYBYTES]; + packing::pack_pk::(&mut pk, &rho, &t1); + let mut tr = [0u8; TR_BYTES]; + fips202::shake256(&mut tr, &pk); + let mut sk = [0u8; SECRETKEYBYTES]; + packing::pack_sk::(&mut sk, &rho, &tr, &key, &t0, &s1, &s2); + zeroize_bytes(&mut key); + + // s1, s2, t0, and s1hat wipe themselves on drop (Polyvec is + // ZeroizeOnDrop); the packed sk moves into a self-wiping buffer and its + // stack copy is scrubbed here. + let secret = SecretKeyBytes(sk.to_vec()); + zeroize_bytes(&mut sk); + HistoricalKeypair { public: pk.to_vec(), secret } +} + fn read_wormhole_secret(path: &std::path::Path) -> Result { let mut hex_str = password::read_secret_file( path.to_str() @@ -419,14 +628,16 @@ struct FoundReward { } /// Where the matching key came from. Holds an index into -/// `Credentials::wormhole_secrets` rather than a copy of the secret. +/// `Credentials::wormhole_secrets` (or a keygen id re-derivable from +/// `Credentials::hd_seed`) rather than a copy of the secret. #[derive(Clone, Debug)] enum RewardSource { Dilithium, + DilithiumHistorical { keygen: String }, Wormhole { secret_index: usize, label: String }, } -fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec { +fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Result> { let mut found = Vec::new(); if let Some(keypair) = &credentials.dilithium { for scheme in DilithiumHash::ALL { @@ -444,6 +655,28 @@ fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec Vec true, + RewardSource::Dilithium | RewardSource::DilithiumHistorical { .. } => true, RewardSource::Wormhole { .. } => found.scheme == CLAIMABLE_WORMHOLE_SCHEME, }; let note = if claimable { "claimable" } else { "not claimable yet" }; @@ -516,7 +751,25 @@ async fn submit_claim( let keypair = credentials.dilithium.as_ref().ok_or_else(|| { QuantusError::Generic("Dilithium match without a loaded wallet".into()) })?; - ClaimBody::Dilithium(build_dilithium_claim(keypair, found.account, *claim_account)?) + ClaimBody::Dilithium(build_dilithium_claim( + &keypair.public_key, + &keypair.private_key, + found.account, + *claim_account, + )?) + }, + RewardSource::DilithiumHistorical { keygen } => { + let seed = credentials.hd_seed.as_ref().ok_or_else(|| { + QuantusError::Generic("historical Dilithium match without a wallet mnemonic".into()) + })?; + // Re-derive the era's keypair; the secret key wipes on drop. + let keypair = derive_historical_dilithium(seed, keygen)?; + ClaimBody::Dilithium(build_dilithium_claim( + &keypair.public, + &keypair.secret.0, + found.account, + *claim_account, + )?) }, RewardSource::Wormhole { secret_index, label } => { if found.scheme != CLAIMABLE_WORMHOLE_SCHEME { @@ -562,26 +815,27 @@ async fn submit_claim( } fn build_dilithium_claim( - keypair: &QuantumKeyPair, + public_key: &[u8], + secret_key: &[u8], address: [u8; 32], claim_account: [u8; 32], ) -> Result { let expiry_unix = now_unix()?.saturating_add(CLAIM_TTL_SECS); let msg = claim_message(&address, &claim_account, expiry_unix); - let secret = SecretKey::from_bytes(&keypair.private_key) + 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) .map_err(|e| QuantusError::Generic(format!("ML-DSA sign failed: {e}")))?; let scheme = DilithiumHash::ALL .iter() - .find(|s| s.derive(&keypair.public_key) == address) + .find(|s| s.derive(public_key) == address) .ok_or_else(|| QuantusError::Generic("could not identify Dilithium hash scheme".into()))?; Ok(DilithiumClaimBody { scheme: scheme.id().to_string(), address: bytes_to_quantus_ss58(&address), claim_account: bytes_to_quantus_ss58(&claim_account), - public_key: hex::encode(&keypair.public_key), + public_key: hex::encode(public_key), signature: hex::encode(signature), expiry_unix, }) @@ -1120,18 +1374,26 @@ mod tests { hex::decode(s).unwrap().try_into().unwrap() } + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + + fn test_seed() -> SensitiveBytes64 { + let mut seed = SensitiveBytes64::zeroed(); + mnemonic_to_seed(TEST_MNEMONIC.into(), None, &mut seed).unwrap(); + seed + } + #[test] fn hd_scan_covers_change_branch_and_multiround_rounds() { - let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon \ - abandon abandon about"; - let secrets = derive_hd_wormhole_secrets(mnemonic, None).unwrap(); - assert_eq!(secrets.len(), 9 * 17); + let secrets = derive_hd_wormhole_secrets(&test_seed(), None).unwrap(); + // Current tree + "Bitcoin seed" tree + the legacy master node. + assert_eq!(secrets.len(), 2 * (9 * 17) + 1); // A `wormhole multiround` round-2 address must be in the scan and match // direct derivation. let path = format!("m/44'/{}/0'/2'/1'", QUANTUS_WORMHOLE_CHAIN_ID); let direct = - qp_rusty_crystals_hdwallet::derive_wormhole_from_mnemonic(mnemonic, None, &path) + qp_rusty_crystals_hdwallet::derive_wormhole_from_mnemonic(TEST_MNEMONIC, None, &path) .unwrap(); let (secret, _) = secrets .iter() @@ -1139,12 +1401,143 @@ mod tests { .expect("round-2 path in scan"); assert_eq!(secret.bytes(), direct.secret().as_bytes()); - // Explicit index still scans every branch/round. - let pinned = derive_hd_wormhole_secrets(mnemonic, Some(1)).unwrap(); - assert_eq!(pinned.len(), 9); + // Explicit index still scans every branch/round in both trees. + let pinned = derive_hd_wormhole_secrets(&test_seed(), Some(1)).unwrap(); + assert_eq!(pinned.len(), 2 * 9 + 1); assert!(pinned.iter().any(|(_, label)| label == &format!("hd {path}"))); } + /// The pre-March-2026 wormhole entropies ("Bitcoin seed" BIP32) pinned by + /// the hdwallet 1.0.0 probe: the master-node secret + /// (`generate_wormhole_pair`) and a path-derived one + /// (`generate_wormhole_pair_from_path`). + #[test] + fn hd_scan_covers_bitcoin_seed_wormhole_entropies() { + let secrets = derive_hd_wormhole_secrets(&test_seed(), None).unwrap(); + let get = |label: &str| { + secrets + .iter() + .find(|(_, l)| l == label) + .unwrap_or_else(|| panic!("{label} missing")) + .0 + .bytes() + .to_owned() + }; + assert_eq!( + get("bitcoin-seed m"), + hex32("1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67") + ); + let path = format!("m/44'/{}/0'/0'/0'", QUANTUS_WORMHOLE_CHAIN_ID); + assert_eq!( + get(&format!("bitcoin-seed {path}")), + hex32("87b3000325d7058a64b01b93f199b3d54ba5d9b2e036cee672199e7da326538c") + ); + } + + /// sha256(pk) vectors computed with the exact shipped crates + /// (`.probe-keygen`): qp-rusty-crystals-dilithium 1.0.3 (pre-FIPS + /// expansion), 2.0.0 (FIPS expansion), qp-rusty-crystals-hdwallet 1.0.0 + /// ("Bitcoin seed" BIP32), and the current 4.1.1. These pin the local + /// keygen reimplementation and the BIP32 tree to the historical bytes. + #[test] + fn historical_dilithium_keygens_match_shipped_crate_vectors() { + use sha2::Digest; + let seed = test_seed(); + let ids = dilithium_keygen_ids(); + let vectors = [ + ("v1:seed", "77993f1dafc02c9162925807f825f611bab071d121d6a42250bc4957c9149562"), + ( + "v1:bip32:m/44'/189189'/0'/0/0", + "57eafbd7c902c02686aff7f39c692beb9f3c057383dc6c954defc381e2c59f7d", + ), + ("fips:seed", "1819feeaba63629813f1266de3d135de22ec505f1e014e669011cd9399bacb6f"), + ( + "fips:bip32:m/44'/189189'/0'/0/0", + "08fffe331b888d215c335e82712aa41ef680edd57e4634a89cca81b87e021e24", + ), + ( + "fips:bip32:m/44'/189189'/0'/0'/0'", + "7aeb9126559a7f750bf90941f632cf1f2835a57500cb1be74d9d3d15007d7e8d", + ), + ( + "fips:bip32:m/44'/189189'/0'", + "a49dac7c3537f61476626d491016abb2ca0e355be017c8cbbabc5a705d1cb5d7", + ), + ("fips:seed32", "2af97815f11fb93d64d0e93fecff2b0e7af88882ed9fda813b5cd6f421799ab2"), + ( + "fips:hd:m/44'/189189'/0'/0'/0'", + "aa46cca1014fa42d40388298b33a7537d6a313b401f5be259cc59797cf4307e7", + ), + ]; + for (id, expected_pk_sha) in vectors { + assert!(ids.iter().any(|i| i == id), "{id} missing from scan list"); + let keypair = derive_historical_dilithium(&seed, id).unwrap(); + assert_eq!(hex::encode(sha2::Sha256::digest(&keypair.public)), expected_pk_sha, "{id}"); + } + // Secret keys too, for the two locally reimplemented expansions: the + // packed sk must be byte-identical to the historical crates' output. + let v1 = derive_historical_dilithium(&seed, "v1:seed").unwrap(); + assert_eq!( + hex::encode(sha2::Sha256::digest(&v1.secret.0)), + "fbcb8f8db649111054baddc24f3eaab314c120950d76fdb8df5c1cd6a4102aa9" + ); + let fips = derive_historical_dilithium(&seed, "fips:seed").unwrap(); + assert_eq!( + hex::encode(sha2::Sha256::digest(&fips.secret.0)), + "fbe63db6ccf71badbb5a4aea59bead046637065fca38270b1a6361644ecf20d3" + ); + } + + /// An address minted by an era-1 wallet (pre-FIPS keygen, soft HD path, + /// v0.8 address hash) is found from the seed alone, and the claim built + /// for it signs with the era's key, verifying under the current crate β€” + /// which is what the claim server runs. + #[test] + fn matches_and_claims_historical_dilithium_address() { + let keygen = "v1:bip32:m/44'/189189'/1'/0/0"; + let seed = test_seed(); + let keypair = derive_historical_dilithium(&seed, keygen).unwrap(); + let address = DilithiumHash::V08Padded.derive(&keypair.public); + let row = SnapshotRow { + address: bytes_to_quantus_ss58(&address), + account: hex::encode(address), + amount_hundredths: 150, + testnets: vec!["Resonance".into()], + kind: "dilithium".into(), + }; + let snapshot = SnapshotFile { + version: 1, + sha256: "0".repeat(64), + rows: vec![row.clone()], + by_account: HashMap::from([(address, row)]), + }; + let credentials = Credentials { + dilithium: None, + wallet_account: None, + wormhole_secrets: Vec::new(), + hd_seed: Some(seed), + }; + + let matches = find_matches(&snapshot, &credentials).unwrap(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].scheme, "dilithium-v08-padded"); + let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else { + panic!("expected a historical Dilithium source"); + }; + assert_eq!(found_keygen, keygen); + + let claim_account = [9u8; 32]; + let body = + build_dilithium_claim(&keypair.public, &keypair.secret.0, address, claim_account) + .unwrap(); + assert_eq!(body.scheme, "dilithium-v08-padded"); + let msg = claim_message(&address, &claim_account, body.expiry_unix); + let public = + qp_rusty_crystals_dilithium::ml_dsa_87::PublicKey::from_bytes(&keypair.public).unwrap(); + let sig = hex::decode(&body.signature).unwrap(); + assert!(public.verify(&msg, &sig, Some(CLAIM_CONTEXT))); + } + #[test] fn claim_message_is_address_dest_expiry() { let address = [1u8; 32]; @@ -1288,6 +1681,7 @@ mod tests { dilithium: None, wallet_account: Some([5u8; 32]), wormhole_secrets: Vec::new(), + hd_seed: None, }; let dest = [7u8; 32]; let resolved = @@ -1304,14 +1698,19 @@ mod tests { dilithium: None, wallet_account: Some([5u8; 32]), wormhole_secrets: Vec::new(), + hd_seed: None, }; assert_eq!(resolve_claim_account(None, &credentials).unwrap(), [5u8; 32]); } #[test] fn resolve_claim_account_requires_to_without_wallet() { - let credentials = - Credentials { dilithium: None, wallet_account: None, wormhole_secrets: Vec::new() }; + let credentials = Credentials { + dilithium: None, + wallet_account: None, + wormhole_secrets: Vec::new(), + hd_seed: None, + }; assert!(resolve_claim_account(None, &credentials).is_err()); } From fbe50d27c82bf1bfca80b964417099949059fbe1 Mon Sep 17 00:00:00 2001 From: illuzen Date: Fri, 18 Sep 2026 14:51:54 +0800 Subject: [PATCH 10/11] Decode wormhole secret hex without heap copies; add scan-window controls parse_secret_hex now decodes into a stack buffer via hex::decode_to_slice (hex::decode freed a Vec holding the credential unscrubbed), scrubbing partial output on error; the allocator regression covers ingestion on success and error paths. airdrop check/claim gain --scan-accounts and --scan-rounds to widen the Dilithium account and wormhole round windows, and the wallet's stored derivation path is always scanned under every keygen era (eras that cannot express the path are skipped). Co-authored-by: Cursor --- src/cli/airdrop.rs | 286 +++++++++++++++++++++++++++++++++++++------- src/cli/wormhole.rs | 30 +++-- 2 files changed, 261 insertions(+), 55 deletions(-) diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 653b642..115793b 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -25,15 +25,9 @@ const CLAIM_CONTEXT: &[u8] = b"qp-airdrop-claim-v1"; const CLAIM_TTL_SECS: i64 = 10 * 60; const DEFAULT_SERVER: &str = "http://127.0.0.1:8080"; const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive = 0..=16; -/// Middle HD path component. The mobile app uses 0 (external) and 1 (change); -/// `wormhole multiround` uses it as a round counter (default 2 rounds), so -/// scan several rounds beyond that. -const HD_WORMHOLE_BRANCHES: std::ops::RangeInclusive = 0..=8; const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact"; /// BIP44 coin type for Dilithium keys (the wormhole coin type is 189189189'). const DILITHIUM_CHAIN_ID: &str = "189189'"; -/// Account indexes scanned per historical Dilithium keygen family. -const DILITHIUM_SCAN_ACCOUNTS: u32 = 9; #[derive(Subcommand, Debug)] pub enum AirdropCommands { @@ -59,10 +53,23 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, - /// HD wormhole address index, scanned across branches/rounds 0..=8 + /// HD wormhole address index, scanned across every branch/round /// (default: scan indexes 0..=16) #[arg(long)] wormhole_index: Option, + + /// Highest Dilithium account index scanned per historical keygen + /// family (m/44'/189189'/N'/…). Raise if the wallet was created with + /// a higher account; the wallet's own stored derivation path is + /// always scanned. + #[arg(long, default_value_t = 8)] + scan_accounts: u32, + + /// Highest wormhole branch/round component scanned + /// (m/44'/189189189'/0'/N'/index'). Raise if `wormhole multiround` + /// was run with more than this many rounds. + #[arg(long, default_value_t = 8)] + scan_rounds: usize, }, /// Prove ownership and submit claims. Amounts come from the snapshot. @@ -91,11 +98,24 @@ pub enum AirdropCommands { #[arg(long)] wormhole_secret_file: Option, - /// HD wormhole address index, scanned across branches/rounds 0..=8 + /// HD wormhole address index, scanned across every branch/round /// (default: scan indexes 0..=16) #[arg(long)] wormhole_index: Option, + /// Highest Dilithium account index scanned per historical keygen + /// family (m/44'/189189'/N'/…). Raise if the wallet was created with + /// a higher account; the wallet's own stored derivation path is + /// always scanned. + #[arg(long, default_value_t = 8)] + scan_accounts: u32, + + /// Highest wormhole branch/round component scanned + /// (m/44'/189189189'/0'/N'/index'). Raise if `wormhole multiround` + /// was run with more than this many rounds. + #[arg(long, default_value_t = 8)] + scan_rounds: usize, + /// Print matches and signed/proved payloads without POSTing #[arg(long)] dry_run: bool, @@ -111,6 +131,8 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { password_file, wormhole_secret_file, wormhole_index, + scan_accounts, + scan_rounds, } => handle_check( server, @@ -118,7 +140,7 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { password, password_file, wormhole_secret_file, - wormhole_index, + ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds }, ) .await, AirdropCommands::Claim { @@ -129,6 +151,8 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { password_file, wormhole_secret_file, wormhole_index, + scan_accounts, + scan_rounds, dry_run, } => handle_claim( @@ -138,20 +162,33 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { password, password_file, wormhole_secret_file, - wormhole_index, + ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds }, dry_run, ) .await, } } +/// How far the deterministic recovery scan reaches. The defaults cover every +/// path the app or this CLI ever created on its own; the flags exist for +/// wallets that used custom accounts or extra multiround rounds. +#[derive(Clone, Copy)] +struct ScanWindow { + /// Pin the wormhole address index (None: scan 0..=16). + wormhole_index: Option, + /// Highest Dilithium account index per historical keygen family. + accounts: u32, + /// Highest wormhole branch/round component. + rounds: usize, +} + async fn handle_check( server: String, wallet: Option, password: Option, password_file: Option, wormhole_secret_file: Option, - wormhole_index: Option, + scan: ScanWindow, ) -> Result<()> { let snapshot = fetch_snapshot(&server).await?; let credentials = collect_credentials( @@ -159,13 +196,13 @@ async fn handle_check( password, password_file, wormhole_secret_file.as_deref(), - wormhole_index, + scan, )?; if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() { return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); } - let matches = find_matches(&snapshot, &credentials)?; + let matches = find_matches(&snapshot, &credentials); print_snapshot_header(&snapshot); print_matches(&matches); Ok(()) @@ -178,7 +215,7 @@ async fn handle_claim( password: Option, password_file: Option, wormhole_secret_file: Option, - wormhole_index: Option, + scan: ScanWindow, dry_run: bool, ) -> Result<()> { let credentials = collect_credentials( @@ -186,7 +223,7 @@ async fn handle_claim( password, password_file, wormhole_secret_file.as_deref(), - wormhole_index, + scan, )?; if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() { return Err(QuantusError::Generic("provide --wallet and/or --wormhole-secret-file".into())); @@ -194,7 +231,7 @@ async fn handle_claim( let claim_account = resolve_claim_account(to.as_deref(), &credentials)?; let snapshot = fetch_snapshot(&server).await?; - let matches = find_matches(&snapshot, &credentials)?; + let matches = find_matches(&snapshot, &credentials); print_snapshot_header(&snapshot); print_matches(&matches); @@ -288,6 +325,12 @@ struct Credentials { /// keypairs can be re-derived at claim time instead of holding every /// candidate secret key in memory. hd_seed: Option, + /// The wallet's stored derivation path: custom `--derivation-path` + /// imports can sit outside the scanned account window, so this exact + /// path is scanned under every keygen era too. + wallet_derivation_path: Option, + /// Highest Dilithium account index to scan per keygen family. + scan_accounts: u32, } fn collect_credentials( @@ -295,16 +338,19 @@ fn collect_credentials( password: Option, password_file: Option, wormhole_secret_file: Option<&std::path::Path>, - wormhole_index: Option, + scan: ScanWindow, ) -> Result { let mut wormhole_secrets = Vec::new(); let mut dilithium = None; let mut wallet_account = None; let mut hd_seed = None; + let mut wallet_derivation_path = None; if let Some(name) = wallet { - let (keypair, mnemonic) = load_wallet_material(name, password, password_file)?; + let (keypair, mnemonic, derivation_path) = + load_wallet_material(name, password, password_file)?; wallet_account = Some(*keypair.try_to_account_id_32()?.as_ref()); + wallet_derivation_path = derivation_path; if keypair.scheme != DilithiumScheme::MlDsa87 { log_print!( "Wallet '{}' is {:?}; Dilithium airdrop claims require ML-DSA-87.", @@ -320,7 +366,11 @@ fn collect_credentials( let mut seed = SensitiveBytes64::zeroed(); mnemonic_to_seed(mnemonic, None, &mut seed) .map_err(|e| QuantusError::Generic(format!("invalid mnemonic: {e:?}")))?; - wormhole_secrets.extend(derive_hd_wormhole_secrets(&seed, wormhole_index)?); + wormhole_secrets.extend(derive_hd_wormhole_secrets( + &seed, + scan.wormhole_index, + scan.rounds, + )?); hd_seed = Some(seed); } else { log_verbose!("Wallet '{}' has no mnemonic; HD derivation skipped", name); @@ -332,14 +382,21 @@ fn collect_credentials( wormhole_secrets.push((secret, path.display().to_string())); } - Ok(Credentials { dilithium, wallet_account, wormhole_secrets, hd_seed }) + Ok(Credentials { + dilithium, + wallet_account, + wormhole_secrets, + hd_seed, + wallet_derivation_path, + scan_accounts: scan.accounts, + }) } fn load_wallet_material( wallet_name: &str, password: Option, password_file: Option, -) -> Result<(QuantumKeyPair, Option)> { +) -> Result<(QuantumKeyPair, Option, Option)> { let wallet_manager = WalletManager::new()?; if wallet_manager.wallet_type(wallet_name)? == Some(WalletType::Cold) { return Err(WalletError::ColdWalletNoKeys(wallet_name.to_string()).into()); @@ -347,12 +404,14 @@ fn load_wallet_material( let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; let mnemonic = wallet_data.take_mnemonic(); - Ok((wallet_data.take_keypair(), mnemonic)) + let derivation_path = Some(wallet_data.derivation_path.clone()); + Ok((wallet_data.take_keypair(), mnemonic, derivation_path)) } fn derive_hd_wormhole_secrets( seed: &SensitiveBytes64, wormhole_index: Option, + scan_rounds: usize, ) -> Result> { let indexes: Vec = match wormhole_index { Some(index) => vec![index], @@ -360,7 +419,7 @@ fn derive_hd_wormhole_secrets( }; let mut out = Vec::new(); // Current "Dilithium seed" tree. - for branch in HD_WORMHOLE_BRANCHES { + for branch in 0..=scan_rounds { for &index in &indexes { let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index); let pair = generate_wormhole_from_seed(seed, &path) @@ -373,7 +432,7 @@ fn derive_hd_wormhole_secrets( // used the master node's own key (hdwallet 1.0.0 `generate_wormhole_pair`), // hence path "m". let mut legacy_paths = vec!["m".to_string()]; - for branch in HD_WORMHOLE_BRANCHES { + for branch in 0..=scan_rounds { for &index in &indexes { legacy_paths .push(format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index)); @@ -433,14 +492,18 @@ struct HistoricalKeypair { /// `seed32` (its first half, the chain's `from_seed`), `bip32:` /// ("Bitcoin seed" BIP32 entropy), or `hd:` (current "Dilithium seed" /// derivation). -fn dilithium_keygen_ids() -> Vec { +/// +/// `wallet_path` is the wallet's stored derivation path: a custom +/// `--derivation-path` import can sit outside the account window, so the +/// exact path is scanned under every era too. +fn dilithium_keygen_ids(scan_accounts: u32, wallet_path: Option<&str>) -> Vec { let mut ids = vec![ // Era 1 non-HD (absorbs seed64[..32]); era 2 non-HD; era 4 legacy. "v1:seed".to_string(), "fips:seed".to_string(), "fips:seed32".to_string(), ]; - for account in 0..DILITHIUM_SCAN_ACCOUNTS { + for account in 0..=scan_accounts { // Era 1 and era 2 app/CLI accounts (soft tail), era 3 CLI hardened // default and --no-derivation account child, era 4 current HD. ids.push(format!("v1:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0/0")); @@ -449,6 +512,20 @@ fn dilithium_keygen_ids() -> Vec { ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'")); ids.push(format!("fips:hd:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0'/0'")); } + // "m/" (or "m") is the non-HD marker, covered by the seed ids above. + if let Some(path) = wallet_path.map(|p| p.trim_end_matches('/')) { + if path != "m" && !path.is_empty() { + for id in [ + format!("v1:bip32:{path}"), + format!("fips:bip32:{path}"), + format!("fips:hd:{path}"), + ] { + if !ids.contains(&id) { + ids.push(id); + } + } + } + } ids } @@ -637,7 +714,7 @@ enum RewardSource { Wormhole { secret_index: usize, label: String }, } -fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Result> { +fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec { let mut found = Vec::new(); if let Some(keypair) = &credentials.dilithium { for scheme in DilithiumHash::ALL { @@ -659,8 +736,20 @@ fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Result keypair, + Err(e) => { + // A path an era's tree cannot express (e.g. the wallet's + // stored soft path under the hardened-only current tree) + // had no wallet in that era; skip the candidate. + log_verbose!("keygen {id} skipped: {e}"); + continue; + }, + }; for scheme in DilithiumHash::ALL { let address = scheme.derive(&keypair.public); if let Some(row) = snapshot.by_account.get(&address) { @@ -697,7 +786,7 @@ fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Result SnapshotFile { let row = SnapshotRow { address: bytes_to_quantus_ss58(&address), account: hex::encode(address), @@ -1505,20 +1608,35 @@ mod tests { testnets: vec!["Resonance".into()], kind: "dilithium".into(), }; - let snapshot = SnapshotFile { + SnapshotFile { version: 1, sha256: "0".repeat(64), rows: vec![row.clone()], by_account: HashMap::from([(address, row)]), - }; - let credentials = Credentials { + } + } + + fn seed_only_credentials(seed: SensitiveBytes64) -> Credentials { + Credentials { dilithium: None, wallet_account: None, wormhole_secrets: Vec::new(), hd_seed: Some(seed), - }; + wallet_derivation_path: None, + scan_accounts: 8, + } + } + + #[test] + fn matches_and_claims_historical_dilithium_address() { + let keygen = "v1:bip32:m/44'/189189'/1'/0/0"; + let seed = test_seed(); + let keypair = derive_historical_dilithium(&seed, keygen).unwrap(); + let address = DilithiumHash::V08Padded.derive(&keypair.public); + let snapshot = snapshot_for(address); + let credentials = seed_only_credentials(seed); - let matches = find_matches(&snapshot, &credentials).unwrap(); + let matches = find_matches(&snapshot, &credentials); assert_eq!(matches.len(), 1); assert_eq!(matches[0].scheme, "dilithium-v08-padded"); let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else { @@ -1538,6 +1656,66 @@ mod tests { assert!(public.verify(&msg, &sig, Some(CLAIM_CONTEXT))); } + /// `--scan-accounts` extends the Dilithium window: an account-9 key is + /// outside the default scan but found once the window covers it. + #[test] + fn scan_accounts_flag_reaches_account_nine() { + let keygen = "fips:bip32:m/44'/189189'/9'/0'/0'"; + let seed = test_seed(); + let keypair = derive_historical_dilithium(&seed, keygen).unwrap(); + let address = DilithiumHash::V10Padded.derive(&keypair.public); + let snapshot = snapshot_for(address); + + let mut credentials = seed_only_credentials(seed); + assert!(find_matches(&snapshot, &credentials).is_empty()); + + credentials.scan_accounts = 9; + let matches = find_matches(&snapshot, &credentials); + assert_eq!(matches.len(), 1); + let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else { + panic!("expected a historical Dilithium source"); + }; + assert_eq!(found_keygen, keygen); + } + + /// The wallet's stored derivation path is scanned under every era even + /// when it lies outside the account window β€” including soft paths the + /// hardened-only current tree cannot express (those eras are skipped, + /// not treated as scan failures). + #[test] + fn wallet_derivation_path_is_scanned_across_eras() { + let keygen = "v1:bip32:m/44'/189189'/42'/0/0"; + let seed = test_seed(); + let keypair = derive_historical_dilithium(&seed, keygen).unwrap(); + let address = DilithiumHash::V08Padded.derive(&keypair.public); + let snapshot = snapshot_for(address); + + let mut credentials = seed_only_credentials(seed); + assert!(find_matches(&snapshot, &credentials).is_empty()); + + credentials.wallet_derivation_path = Some("m/44'/189189'/42'/0/0".into()); + let matches = find_matches(&snapshot, &credentials); + assert_eq!(matches.len(), 1); + let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else { + panic!("expected a historical Dilithium source"); + }; + assert_eq!(found_keygen, keygen); + } + + /// The non-HD marker path stored by legacy wallets must not add + /// duplicate candidates (the seed ids already cover it). + #[test] + fn wallet_marker_path_adds_no_candidates() { + assert_eq!(dilithium_keygen_ids(8, Some("m/")), dilithium_keygen_ids(8, None)); + assert_eq!(dilithium_keygen_ids(8, Some("m")), dilithium_keygen_ids(8, None)); + // A path already inside the window only adds era variants the window + // lacks (here just v1:bip32 with a hardened tail). + assert_eq!( + dilithium_keygen_ids(8, Some("m/44'/189189'/0'/0'/0'")).len(), + dilithium_keygen_ids(8, None).len() + 1 + ); + } + #[test] fn claim_message_is_address_dest_expiry() { let address = [1u8; 32]; @@ -1682,6 +1860,8 @@ mod tests { wallet_account: Some([5u8; 32]), wormhole_secrets: Vec::new(), hd_seed: None, + wallet_derivation_path: None, + scan_accounts: 8, }; let dest = [7u8; 32]; let resolved = @@ -1699,6 +1879,8 @@ mod tests { wallet_account: Some([5u8; 32]), wormhole_secrets: Vec::new(), hd_seed: None, + wallet_derivation_path: None, + scan_accounts: 8, }; assert_eq!(resolve_claim_account(None, &credentials).unwrap(), [5u8; 32]); } @@ -1710,6 +1892,8 @@ mod tests { wallet_account: None, wormhole_secrets: Vec::new(), hd_seed: None, + wallet_derivation_path: None, + scan_accounts: 8, }; assert!(resolve_claim_account(None, &credentials).is_err()); } @@ -1755,6 +1939,7 @@ mod heap_zeroization_tests { }; use super::{injective4_secret_words, WormholeHash}; + use crate::cli::wormhole::parse_secret_hex; /// Distinctive all-ASCII 32-byte pattern; see module docs for why ASCII /// makes the compact8 felt image identical to the raw bytes. @@ -1810,6 +1995,17 @@ mod heap_zeroization_tests { let derived = scheme.derive(&SECRET_PATTERN); core::hint::black_box(derived.address); } + // Explicit-secret ingestion: `parse_secret_hex` decodes into a stack + // buffer (`hex::decode` would free a Vec holding the credential + // unscrubbed), on error paths too. + let secret_hex = hex::encode(SECRET_PATTERN); + let parsed = parse_secret_hex(&secret_hex).expect("valid secret hex"); + core::hint::black_box(parsed); + let mut bad_digit = hex::encode(&SECRET_PATTERN[..31]); + bad_digit.push_str("zz"); + assert!(parse_secret_hex(&bad_digit).is_err()); + assert!(parse_secret_hex(&secret_hex[..62]).is_err()); + assert!(parse_secret_hex(&secret_hex[..63]).is_err()); SCANNING.store(false, Ordering::SeqCst); let leaked = LEAKED_BLOCK_SIZE.load(Ordering::SeqCst); diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index ecf0271..28544f1 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -313,18 +313,28 @@ pub fn compute_merkle_positions( (sorted_siblings, positions) } -/// Parse a hex-encoded secret string into a 32-byte array +/// Parse a hex-encoded secret string into a 32-byte array. +/// +/// Decodes straight into a fixed stack buffer: `hex::decode` would put the +/// spend secret into a heap `Vec` whose backing block is freed unscrubbed +/// when the bytes are moved out (security review). pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> { - let secret_bytes = hex::decode(secret_hex.trim_start_matches("0x")) - .map_err(|e| format!("Invalid secret hex: {}", e))?; - - if secret_bytes.len() != 32 { - return Err(format!("Secret must be exactly 32 bytes, got {} bytes", secret_bytes.len())); + let hex_str = secret_hex.trim_start_matches("0x"); + if !hex_str.len().is_multiple_of(2) { + return Err("Invalid secret hex: odd number of digits".to_string()); + } + if hex_str.len() != 64 { + return Err(format!("Secret must be exactly 32 bytes, got {} bytes", hex_str.len() / 2)); + } + let mut secret = [0u8; 32]; + match hex::decode_to_slice(hex_str, &mut secret) { + Ok(()) => Ok(secret), + Err(e) => { + // A partial prefix may have decoded before the bad digit. + crate::wallet::keystore::zeroize_bytes(&mut secret); + Err(format!("Invalid secret hex: {}", e)) + }, } - - secret_bytes - .try_into() - .map_err(|_| "Failed to convert secret to 32-byte array".to_string()) } /// Read a hex-encoded secret from a file and validate that it is exactly 32 bytes. From 0cb24f1b7c1c26fd6598b32dcd776e1bef4ded86 Mon Sep 17 00:00:00 2001 From: illuzen Date: Fri, 18 Sep 2026 17:38:54 +0800 Subject: [PATCH 11/11] Update rustls to 0.23.45 (RUSTSEC-2026-0285) Co-authored-by: Cursor --- Cargo.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 071db2c..b3b8c67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5662,9 +5662,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -5748,9 +5748,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7245,7 +7245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -7742,7 +7742,7 @@ version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" dependencies = [ - "cfg-if 1.0.4", + "cfg-if 0.1.10", "digest 0.10.7", "rand 0.8.6", "static_assertions",