Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/cli/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ pub enum WalletCommands {
output: Option<std::path::PathBuf>,
},

/// Import wallet from mnemonic phrase
/// Import wallet from mnemonic phrase or hex seed
Import {
/// Wallet name
#[arg(short, long)]
Expand All @@ -106,6 +106,12 @@ pub enum WalletCommands {
#[arg(long)]
mnemonic_file: Option<String>,

/// 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)]
Expand Down Expand Up @@ -1052,6 +1058,7 @@ pub async fn handle_wallet_command(
password_file,
allow_empty_password,
mnemonic_file,
seed,
derivation_path,
no_derivation,
scheme,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 144 additions & 0 deletions src/wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WalletInfo> {
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<Option<WalletInfo>> {
let keystore = Keystore::new(&self.wallets_dir);
Expand Down Expand Up @@ -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());
}
}
}