diff --git a/src/commands/vault.rs b/src/commands/vault.rs index e6cf88bd29e15edd1f4cddbaac2d081e6fad9e23..b46606e1d2171dbe2d16b5ee98d0658b57ae38e1 100644 --- a/src/commands/vault.rs +++ b/src/commands/vault.rs @@ -1,6 +1,6 @@ mod display; -use crate::commands::cesium::compute_g1v1_public_key; +use crate::commands::cesium; use crate::entities::vault_account; use crate::entities::vault_account::{AccountTreeNode, ActiveModel, DbAccountId}; use crate::inputs::{trim_and_reduce_empty_as_none, validate_derivation_path, validate_vault_name}; @@ -114,7 +114,7 @@ pub enum Subcommand { #[clap(flatten)] address_or_vault_name: AddressOrVaultNameGroup, }, - /// Inspect a vault entry, retrieving its Substrate URI (will provide more data in a future version) + /// Inspect a vault entry, retrieving its Substrate URI, Crypto-Scheme, Secret seed/mini-secret(if possible), Public key (hex), SS58 Address and potential G1v1 public key if inspecting a <Base> account with ed25519 crypto-scheme Inspect { #[clap(flatten)] address_or_vault_name: AddressOrVaultNameGroup, @@ -348,7 +348,7 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE if secret_format == SecretFormat::G1v1 { println!( "The G1v1 public key for the provided secret is: '{}'", - compute_g1v1_public_key(&vault_data_for_import.key_pair)? + cesium::compute_g1v1_public_key(&vault_data_for_import.key_pair)? ); // Skip confirmation in non-interactive mode @@ -560,7 +560,9 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let base_account_tree_node = vault_account::get_base_account_tree_node(&account_tree_node_to_inspect); - if !Rc::ptr_eq(&account_tree_node_to_inspect, &base_account_tree_node) { + let is_base_account = + Rc::ptr_eq(&account_tree_node_to_inspect, &base_account_tree_node); + if !is_base_account { let base_account = base_account_tree_node.borrow().account.clone(); println!("The linked <Base> account is {base_account}"); } @@ -583,6 +585,36 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE ))? .into(); println!("Crypto scheme: {}", <&'static str>::from(crypto_scheme)); + + match compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + &account_to_derive_secret_suri, + crypto_scheme, + ) { + Err(e) => { + println!("Secret seed/mini-secret: cannot be computed: {}", e) + } + Ok((_computed_pair, seed)) => { + println!("Secret seed/mini-secret: '0x{}'", hex::encode(seed)); + } + } + + let account_address: AccountId = account_tree_node_to_inspect + .borrow() + .account + .address + .clone() + .into(); + + println!("Public key (hex): '0x{}'", hex::encode(account_address.0)); + + println!("SS58 Address: '{account_address}'"); + + if CryptoScheme::Ed25519 == crypto_scheme && is_base_account { + println!( + "(potential G1v1 public key: '{}')", + cesium::compute_g1v1_public_key_from_ed25519_account_id(&account_address) + ); + } } Subcommand::Migrate => { println!("Migrating existing key files to db"); diff --git a/src/keys.rs b/src/keys.rs index 1108a9677357ce5a8f7ece7b8daa033a0e546cb1..4db3ca22c8ef8554ee8210737447d5f28414d80e 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1,7 +1,9 @@ use crate::commands::vault; use crate::*; -use sp_core::ed25519; -use sp_core::sr25519; +use sp_core::crypto::AddressUri; +use sp_core::crypto::Pair as PairTrait; +use sp_core::DeriveJunction; +use sp_core::{ed25519, sr25519}; pub const SUBSTRATE_MNEMONIC: &str = "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; @@ -235,6 +237,130 @@ pub fn pair_from_ed25519_seed(secret: &str) -> Result<ed25519::Pair, GcliError> Ok(pair) } +/// Check [compute_pair_and_mini_secret_seed_from_suri] method for details +pub fn compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + suri: &str, + crypto_scheme: CryptoScheme, +) -> Result<(KeyPair, [u8; 32]), GcliError> { + match crypto_scheme { + CryptoScheme::Ed25519 => { + let (pair, seed) = + compute_pair_and_mini_secret_seed_from_suri::<sp_core::ed25519::Pair>(suri)?; + Ok((pair.into(), seed)) + } + CryptoScheme::Sr25519 => { + let (pair, seed) = + compute_pair_and_mini_secret_seed_from_suri::<sp_core::sr25519::Pair>(suri)?; + Ok((pair.into(), seed)) + } + } +} + +/// Computes the pair and mini-secret/seed from a Substrate URI for either sr25519 or ed25519. +/// +/// # Arguments +/// * `suri` - The Substrate URI (e.g., mnemonic, hex seed, possibly with HARD derivation(s) like "//0") +/// +/// # Returns +/// A tuple `(P, [u8; 32])` where `P` is the computed `Pair` and `[u8; 32]` is the mini-secret. +/// +/// # Errors +/// Returns `GcliError` if: +/// - The Substrate URI lacks a phrase part +/// - Passwords are present in the derivation +/// - Soft derivation is used (not supported for seed retrieval) +/// - The computed pair's address is different from the one directly retrieved from the `suri` (should not happen) +pub fn compute_pair_and_mini_secret_seed_from_suri<P: PairTrait<Seed = [u8; 32]>>( + suri: &str, +) -> Result<(P, [u8; 32]), GcliError> +where + subxt::utils::AccountId32: std::convert::From<<P as sp_core::Pair>::Public>, +{ + let address_uri = AddressUri::parse(suri).map_err(|e| GcliError::Input(e.to_string()))?; + + if let Some(pass) = address_uri.pass { + return Err(GcliError::Input(format!( + "Having a password in the derivation path is not supported (password:'{}')", + pass + ))); + } + + let base_uri = address_uri.phrase.ok_or(GcliError::Input( + "The `suri` need to contain the 'phrase' part".into(), + ))?; + + let pair_from_suri_for_validation = + P::from_string(suri, None).map_err(|_| GcliError::Input("Invalid secret".to_string()))?; + + // Get the <Base> pair and seed + // If using mini-secret / seed + let (base_pair, base_seed) = if let Some(base_uri_seed) = base_uri.strip_prefix("0x") { + let seed_bytes = hex::decode(base_uri_seed) + .map_err(|e| GcliError::Input(format!("Invalid hex seed: {e}")))?; + let seed: [u8; 32] = seed_bytes + .try_into() + .map_err(|_e| GcliError::Input("Incomplete seed".into()))?; + (P::from_seed(&seed), seed) + } else { + // If using mnemonic / passphrase + let (pair, seed_vec) = P::from_phrase(base_uri, None) + .map_err(|e| GcliError::Input(format!("Invalid mnemonic or passphrase: {e}")))?; + let seed: [u8; 32] = seed_vec + .as_slice() + .try_into() + .map_err(|_e| GcliError::Input("Seed should be 32 bytes".into()))?; + (pair, seed) + }; + + let derivation_paths = address_uri.paths; + + // Apply derivation if present + let (result_pair, result_seed) = if !derivation_paths.is_empty() { + // AddressUri paths have one less '/' everywhere, so matching for no '/' at the start to exclude SOFT derivations + if derivation_paths.iter().any(|path| !path.starts_with("/")) { + return Err(GcliError::Input( + "Soft derivation is not supported when trying to retrieve mini-secret/seed" + .to_string(), + )); + } + + let derive_junctions_from_path = derivation_paths.iter().map(|path| { + if let Some(hard_derivation_no_prefix) = path.strip_prefix("/") { + // AddressUri paths have one less '/' everywhere so this is for HARD derivation + // (and we excluded passwords before; so there won't be any '//') + if let Ok(num) = hard_derivation_no_prefix.parse::<u64>() { + DeriveJunction::hard(num) // Numeric hard derivation + } else { + DeriveJunction::hard(hard_derivation_no_prefix) // String hard derivation + } + } else { + unreachable!("Should not have SOFT derivation detected here"); + } + }); + + let (derived_pair, derived_seed_opt) = base_pair + .derive(derive_junctions_from_path.into_iter(), Some(base_seed)) + .map_err(|e| GcliError::Input(format!("Failed to derive key: {e}")))?; + let derived_seed = derived_seed_opt.ok_or(GcliError::Input(format!("Derived seed should be present when base seed is provided (and no soft derivation is used) - base seed was:'0x{}'",hex::encode(base_seed))))?; + let seed_array: [u8; 32] = derived_seed + .as_slice() + .try_into() + .map_err(|_e| GcliError::Input("Derived seed should be 32 bytes".into()))?; + + (derived_pair, seed_array) + } else { + (base_pair, base_seed) + }; + + let result_pair_account_id: AccountId = result_pair.public().into(); + let pair_from_suri_account_id: AccountId = pair_from_suri_for_validation.public().into(); + if result_pair_account_id != pair_from_suri_account_id { + return Err(GcliError::Input(format!("Computed pair has a different address: '{result_pair_account_id}' != '{pair_from_suri_account_id}'"))); + } + + Ok((result_pair, result_seed)) +} + /// get mnemonic from predefined derivation path pub fn predefined_suri(deriv: &str) -> String { format!("{SUBSTRATE_MNEMONIC}//{deriv}") @@ -534,6 +660,7 @@ mod tests { mod substrate { use super::*; + use crate::keys::SUBSTRATE_MNEMONIC; /// Testing sr25519 mnemonic derivations /// @@ -772,7 +899,7 @@ mod tests { } } - mod cesium { + mod g1v1 { use super::*; /// Test which verifies that it's possible to derive a key coming from a cesium v1 id & password @@ -806,7 +933,7 @@ mod tests { /// SS58 Address: 5Ca1HrNxQ4hiekd92Z99fzhfdSAqPy2rUkLBmwLsgLCjeSQf /// ``` #[test] - fn test_cesium_v1_key_derivation() { + fn test_g1v1_key_derivation() { let cesium_id = "test_cesium_id".to_string(); let cesium_pwd = "test_cesium_pwd".to_string(); @@ -919,7 +1046,7 @@ mod tests { /// SS58 Address: 5ET2jhgJFoNQUpgfdSkdwftK8DKWdqZ1FKm5GKWdPfMWhPr4 /// ``` #[test] - fn test_cesium_v1_seed_using_scrypt() { + fn test_g1v1_seed_using_scrypt() { let cesium_id = "test_cesium_id".to_string(); let cesium_pwd = "test_cesium_pwd".to_string(); @@ -963,4 +1090,239 @@ mod tests { ); } } + + mod parameterized_tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk"), + String::from("5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV"), + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e") + )] + #[case( + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e"), + String::from("5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV"), + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0" + ), + String::from("5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH"), + String::from("0x914dded06277afbe5b0e8a30bce539ec8a9552a784d08e530dc7c2915c478393") + )] + #[case( + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e//0"), + String::from("5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH"), + String::from("0x914dded06277afbe5b0e8a30bce539ec8a9552a784d08e530dc7c2915c478393") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0//1" + ), + String::from("5Cr4pXhwGbmjQpWw86zYcym5QiixA9XEyMujtiFmHLMNE1wB"), + String::from("0x73fdf8bcfde4b4b014c84624f1e6f44ebd77e68c971780d31d09fa7699892c2e") + )] + #[case( + String::from( + "0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e//0//1" + ), + String::from("5Cr4pXhwGbmjQpWw86zYcym5QiixA9XEyMujtiFmHLMNE1wB"), + String::from("0x73fdf8bcfde4b4b014c84624f1e6f44ebd77e68c971780d31d09fa7699892c2e") + )] + #[case( + String::from("0x914dded06277afbe5b0e8a30bce539ec8a9552a784d08e530dc7c2915c478393//1"), + String::from("5Cr4pXhwGbmjQpWw86zYcym5QiixA9XEyMujtiFmHLMNE1wB"), + String::from("0x73fdf8bcfde4b4b014c84624f1e6f44ebd77e68c971780d31d09fa7699892c2e") + )] + #[case( + String::from("0x73fdf8bcfde4b4b014c84624f1e6f44ebd77e68c971780d31d09fa7699892c2e"), + String::from("5Cr4pXhwGbmjQpWw86zYcym5QiixA9XEyMujtiFmHLMNE1wB"), + String::from("0x73fdf8bcfde4b4b014c84624f1e6f44ebd77e68c971780d31d09fa7699892c2e") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice" + ), + String::from("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"), + String::from("0xe5be9a5092b81bca64be81d212e7f2f9eba183bb7a90954f7b76361f6edb5c0a") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice//Alan" + ), + String::from("5HHCrQYbfr1n9me9LoB4REHZZ9BQZAcw2dnbUKdzdXkFLEr6"), + String::from("0x06ace9363c66d855542d55d8b233c697db3bd7fe2fbdc6c34cefb94ad43eccf0") + )] + /// Expected data was retrieved using `subkey inspect` command + fn sr25519_compute_pair_and_mini_secret_seed_from_suri( + #[case] suri_string: String, + #[case] expected_address: String, + #[case] expected_seed: String, + ) { + let (keypair, seed) = compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + &suri_string, + CryptoScheme::Sr25519, + ) + .unwrap(); + + assert_eq!(format!("0x{}", hex::encode(seed)), expected_seed); + + assert_eq!(keypair.address().to_string(), expected_address) + } + + #[rstest] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk/0"), + String::from("Input(\"Soft derivation is not supported when trying to retrieve mini-secret/seed\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk//0/1"), + String::from("Input(\"Soft derivation is not supported when trying to retrieve mini-secret/seed\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk///pass"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass')\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk///pass//0"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass//0')\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk//0///pass//1"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass//1')\")") + )] + fn sr25519_compute_pair_and_mini_secret_seed_from_suri_expecting_errors( + #[case] suri_string: String, + #[case] expected_error: String, + ) { + let result = compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + &suri_string, + CryptoScheme::Sr25519, + ); + + match result { + Ok(_) => assert!(false, "expected error"), + Err(e) => assert_eq!(expected_error, e.to_string()), + } + } + + #[rstest] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk"), + String::from("5DFJF7tY4bpbpcKPJcBTQaKuCDEPCpiz8TRjpmLeTtweqmXL"), + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e") + )] + #[case( + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e"), + String::from("5DFJF7tY4bpbpcKPJcBTQaKuCDEPCpiz8TRjpmLeTtweqmXL"), + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0" + ), + String::from("5HrCphkqYygSXWt9rHebqaqbfEYekhzjyjQNjZiPxpb3XsKY"), + String::from("0xf8dfdb0f1103d9fb2905204ac32529d5f148761c4321b2865b0a40e15be75f57") + )] + #[case( + String::from("0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e//0"), + String::from("5HrCphkqYygSXWt9rHebqaqbfEYekhzjyjQNjZiPxpb3XsKY"), + String::from("0xf8dfdb0f1103d9fb2905204ac32529d5f148761c4321b2865b0a40e15be75f57") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0//1" + ), + String::from("5G4xo2TvB4Uv7MR1b35yWBW8g7WLaiuETWChtX1dHkyHrLEP"), + String::from("0xb91db3bc88f1302eff28cb05a3ca963d78b39cb8bef86837f80c1a5f8181c6ab") + )] + #[case( + String::from( + "0xfac7959dbfe72f052e5a0c3c8d6530f202b02fd8f9f5ca3580ec8deb7797479e//0//1" + ), + String::from("5G4xo2TvB4Uv7MR1b35yWBW8g7WLaiuETWChtX1dHkyHrLEP"), + String::from("0xb91db3bc88f1302eff28cb05a3ca963d78b39cb8bef86837f80c1a5f8181c6ab") + )] + #[case( + String::from("0xf8dfdb0f1103d9fb2905204ac32529d5f148761c4321b2865b0a40e15be75f57//1"), + String::from("5G4xo2TvB4Uv7MR1b35yWBW8g7WLaiuETWChtX1dHkyHrLEP"), + String::from("0xb91db3bc88f1302eff28cb05a3ca963d78b39cb8bef86837f80c1a5f8181c6ab") + )] + #[case( + String::from("0xb91db3bc88f1302eff28cb05a3ca963d78b39cb8bef86837f80c1a5f8181c6ab"), + String::from("5G4xo2TvB4Uv7MR1b35yWBW8g7WLaiuETWChtX1dHkyHrLEP"), + String::from("0xb91db3bc88f1302eff28cb05a3ca963d78b39cb8bef86837f80c1a5f8181c6ab") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice" + ), + String::from("5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu"), + String::from("0xabf8e5bdbe30c65656c0a3cbd181ff8a56294a69dfedd27982aace4a76909115") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk//Alice//Alan" + ), + String::from("5CvaztNtrHKyi4vPK4xHNRCyLdoWTFovSr9g2MekGUGEvBaS"), + String::from("0xb1f3996e8083bda16a43abfdbea6549bbfecdcc4b5c043a73645fff232765fca") + )] + /// Expected data was retrieved using `subkey inspect --scheme ed25519` command + fn ed25519_compute_pair_and_mini_secret_seed_from_suri( + #[case] suri_string: String, + #[case] expected_address: String, + #[case] expected_seed: String, + ) { + let (keypair, seed) = compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + &suri_string, + CryptoScheme::Ed25519, + ) + .unwrap(); + + assert_eq!(format!("0x{}", hex::encode(seed)), expected_seed); + + assert_eq!(keypair.address().to_string(), expected_address) + } + + #[rstest] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk/0" + ), + String::from("Input(\"Invalid secret\")") + )] + #[case( + String::from( + "bottom drive obey lake curtain smoke basket hold race lonely fit walk//0/1" + ), + String::from("Input(\"Invalid secret\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk///pass"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass')\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk///pass//0"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass//0')\")") + )] + #[case( + String::from("bottom drive obey lake curtain smoke basket hold race lonely fit walk//0///pass//1"), + String::from("Input(\"Having a password in the derivation path is not supported (password:'pass//1')\")") + )] + fn ed25519_compute_pair_and_mini_secret_seed_from_suri_expecting_errors( + #[case] suri_string: String, + #[case] expected_error: String, + ) { + let result = compute_pair_and_mini_secret_seed_from_suri_and_crypto_scheme( + &suri_string, + CryptoScheme::Ed25519, + ); + + match result { + Ok(_) => assert!(false, "expected error"), + Err(e) => assert_eq!(expected_error, e.to_string()), + } + } + } }