From 22498eda9e22bd149cb06224125ab2ab20613750 Mon Sep 17 00:00:00 2001 From: Rick Glenn Date: Fri, 23 Oct 2026 07:51:05 -0700 Subject: [PATCH] Add wallet import --seed for hex seeds Reads a 64-byte (quantus-node key generate) or 32-byte hex seed, optional 0x, from a hidden prompt and derives through the same master-seed path as --no-derivation. Non-hex input and any other length are rejected. Closes #13 --- src/cli/wallet.rs | 30 +++++++++- src/error.rs | 2 + src/wallet/mod.rs | 144 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 2 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 01c96826..e7ea33a6 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -83,7 +83,7 @@ pub enum WalletCommands { output: Option, }, - /// Import wallet from mnemonic phrase + /// Import wallet from mnemonic phrase or hex seed Import { /// Wallet name #[arg(short, long)] @@ -106,6 +106,12 @@ pub enum WalletCommands { #[arg(long)] mnemonic_file: Option, + /// Import from a hex seed (optional 0x) read from a hidden prompt instead of a + /// mnemonic: the 64-byte seed printed by `quantus-node key generate`, or a 32-byte + /// seed. Node keys are ML-DSA-87; pass --scheme ml-dsa-87 to match a node address + #[arg(long, conflicts_with_all = ["mnemonic_file", "derivation_path", "no_derivation", "scan"])] + seed: bool, + /// Derivation path (default depends on --scheme: ML-DSA-65 → /// m/44'/189189'/0'/0'/1', ML-DSA-87 → m/44'/189189'/0'/0'/0') #[arg(short = 'd', long)] @@ -1052,6 +1058,7 @@ pub async fn handle_wallet_command( password_file, allow_empty_password, mnemonic_file, + seed, derivation_path, no_derivation, scheme, @@ -1088,10 +1095,27 @@ pub async fn handle_wallet_command( let mut mnemonic_phrase = match file_mnemonic { Some(mnemonic) => mnemonic, + None if seed => String::new(), None => get_mnemonic_from_user()?, }; - let result = if scan { + let result = if seed { + log_print!("Enter hex seed (64 or 128 hex characters, optional 0x):"); + let mut seed_raw = rpassword::read_password() + .map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?; + let mut seed_hex = seed_raw.trim().to_string(); + crate::wallet::keystore::zeroize_string(&mut seed_raw); + let result = wallet_manager + .import_wallet_from_hex_seed_with_scheme( + &name, + &seed_hex, + Some(&final_password), + scheme, + ) + .await; + crate::wallet::keystore::zeroize_string(&mut seed_hex); + result + } else if scan { match choose_recovery_candidate(&mnemonic_phrase, node_url, offline).await { Ok(candidate) => wallet_manager @@ -1582,6 +1606,7 @@ mod tests { password_file: None, allow_empty_password: true, mnemonic_file, + seed: false, derivation_path: None, no_derivation: false, scheme: DilithiumScheme::MlDsa65, @@ -1608,6 +1633,7 @@ mod tests { password_file: None, allow_empty_password: false, mnemonic_file: Some(missing_mnemonic.to_string_lossy().into_owned()), + seed: false, derivation_path: None, no_derivation: false, scheme: DilithiumScheme::MlDsa65, diff --git a/src/error.rs b/src/error.rs index c20d6b21..31a07d42 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,6 +49,8 @@ pub enum WalletError { #[error("Invalid mnemonic phrase")] InvalidMnemonic, + #[error("Invalid seed: {0}")] + InvalidSeed(String), #[error("Mnemonic phrase is not available for this wallet")] MnemonicNotAvailable, // Decryption cannot distinguish a wrong password from a corrupted/tampered diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index dc02263d..3822b7ed 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -823,6 +823,66 @@ impl WalletManager { }) } + /// Import wallet from a hex seed (optional `0x`): the 64-byte BIP39 seed printed by + /// `quantus-node key generate`, or a 32-byte seed. Same keypair as a mnemonic import + /// with `--no-derivation` for the same seed. + pub async fn import_wallet_from_hex_seed_with_scheme( + &self, + name: &str, + seed_hex: &str, + password: Option<&str>, + scheme: DilithiumScheme, + ) -> Result { + let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; + if keystore.load_wallet(name)?.is_some() { + return Err(WalletError::AlreadyExists.into()); + } + + let hex_str = seed_hex.strip_prefix("0x").unwrap_or(seed_hex); + if hex_str.len() != 64 && hex_str.len() != 128 { + return Err(WalletError::InvalidSeed(format!( + "expected 64 or 128 hex characters (32 or 64 bytes), got {}", + hex_str.len() + )) + .into()); + } + let mut seed_bytes = hex::decode(hex_str) + .map_err(|_| WalletError::InvalidSeed("not a valid hex string".to_string()))?; + + let quantum_keypair = pair_from_master_seed(&seed_bytes, scheme); + keystore::zeroize_bytes(&mut seed_bytes); + let quantum_keypair = quantum_keypair?; + + let mut metadata = std::collections::HashMap::new(); + metadata.insert("version".to_string(), "1.0.0".to_string()); + metadata.insert("algorithm".to_string(), scheme.algorithm_label().to_string()); + metadata.insert("imported".to_string(), "true".to_string()); + metadata.insert("from_seed".to_string(), "true".to_string()); + + let address = quantum_keypair.try_to_account_id_ss58check()?; + + let wallet_data = WalletData { + name: name.to_string(), + keypair: quantum_keypair, + mnemonic: None, + derivation_path: "master".to_string(), + metadata, + }; + + let password = password.unwrap_or(""); + let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; + keystore.save_new_wallet(&encrypted_wallet)?; + + Ok(WalletInfo { + name: name.to_string(), + address, + created_at: encrypted_wallet.created_at, + key_type: scheme.key_type_label().to_string(), + derivation_path: "master".to_string(), + }) + } + /// Get wallet by name with password for decryption pub fn get_wallet(&self, name: &str, password: Option<&str>) -> Result> { let keystore = Keystore::new(&self.wallets_dir); @@ -2118,4 +2178,88 @@ mod tests { "forged-address wallet must be omitted from listing" ); } + + #[tokio::test] + async fn test_import_from_hex_seed_matches_no_derivation_import() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + let mut seed64 = SensitiveBytes64::zeroed(); + mnemonic_to_seed(mnemonic.to_string(), None, &mut seed64).unwrap(); + let seed_hex = hex::encode(seed64.as_bytes()); + + for scheme in [DilithiumScheme::MlDsa65, DilithiumScheme::MlDsa87] { + let expected = wallet_manager + .import_wallet_no_derivation_with_scheme( + &format!("mnemonic-{scheme}"), + mnemonic, + None, + scheme, + ) + .await + .unwrap(); + let from_seed = wallet_manager + .import_wallet_from_hex_seed_with_scheme( + &format!("seed-{scheme}"), + &format!("0x{seed_hex}"), + None, + scheme, + ) + .await + .unwrap(); + assert_eq!(from_seed.address, expected.address); + + let from_seed32 = wallet_manager + .import_wallet_from_hex_seed_with_scheme( + &format!("seed32-{scheme}"), + &seed_hex[..64], + None, + scheme, + ) + .await + .unwrap(); + let expected32 = wallet_manager + .create_wallet_from_seed_with_scheme( + &format!("from-seed-{scheme}"), + &seed_hex[..64], + None, + scheme, + ) + .await + .unwrap(); + assert_eq!(from_seed32.address, expected32.address); + } + } + + #[tokio::test] + async fn test_import_from_hex_seed_rejects_malformed_seeds() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let bad_hex = format!("{}zz", "ab".repeat(63)); + let cases = [ + bad_hex.clone(), + format!("0x{bad_hex}"), + "ab".repeat(31), + "ab".repeat(48), + "ab".repeat(65), + format!("{}a", "ab".repeat(64)), + "0x0x".to_string() + &"ab".repeat(63), + String::new(), + ]; + for (i, seed) in cases.iter().enumerate() { + let name = format!("bad-seed-{i}"); + let err = wallet_manager + .import_wallet_from_hex_seed_with_scheme( + &name, + seed, + None, + DilithiumScheme::MlDsa65, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Invalid seed"), "case {i}: {err}"); + assert!(Keystore::new(&wallet_manager.wallets_dir) + .load_wallet(&name) + .unwrap() + .is_none()); + } + } }