diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f1d0018beab9bfbbfcb94b8224d8dbc51fb5d4..c772b67fb26691b568f14df50f6c7a152ba34634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ List of changelogs ordered from latest to oldest +## [0.4.1] - 2025-04-08 +### Added / Changed +- We now use `ed25519` crypto scheme by default for all commands. It means that if you import your substrate mnemonic without giving a specific argument to change the crypto scheme, the resulting SS58 address will not be the same as before (it was using `sr25519` for substrate mnemonic previously) + - Extra `-c` / `--crypto-scheme` argument allows to specify which crypto scheme to use (`sr25519` or `ed25519`) and having a default value of `ed25519` in most places. + - this argument is present in all places where you could provide `-S` / `--secret-format` argument + - Due to that change, the display of the vault (`vault list ...` commands) will not display the g1v1 public key of all `ed25519` \<Base\> keys by default anymore; you will have to provide an extra `--show-g1v1` argument for that. + - Another impact is that we can now `vault derive` any key; including the ones with `ed25519` crypto-scheme. + - It is still highly recommended to **only derive** keys based on a **substrate mnemonic**; as old `g1v1` keys with their _manual_ `id` & `secret` are not as secure as using a generated substrate mnemonic. + - There is an exception to the impacted commands: `vault migrate` will still import old key files as `sr25519` crypto-scheme keys; as it would otherwise not correspond to the SS58 Address of those key files. +- It is now possible to perform `vault import` and `vault derive` commands without interactive prompts if all necessary optional arguments are provided. + - Please check for more details with commands: + - `vault import --help` + - `vault derive --help` + - If the command tries to override an existing vault entry, then it will still require manual input from the user! +- Adapted commands `identity link-account` and `identity change-owner-key` + - Made it clear that the arguments are meant to "target" another account + - Added possibility to provide the "target" account from the vault + - `-a` SS58 Address of target vault account + - `-v` Name of target vault account + - `-S` (secret_format) doesn't have a default anymore since it conflicts with using `-a` or `-v` +- Adapted `vault inspect` command to display more information + - Substrate URI (was already present) + - Crypto scheme: to be more coherent since both the `substrate uri` and `crypto-scheme` are needed to create the key with the proper `address` + - Secret seed/mini-secret: only computed if no `soft` (single '/') derivation are used + - Public key (hex) + - SS58 Address + - (potential G1v1 public key if inspecting a \<Base\> account with ed25519 crypto-scheme) +- Small changes + - In the vault, changed sorting of children account so that they are sorted by derivation `Path` value (makes more sense when viewing derivations of one account) + +### Fixed +- None + +### Deprecated +- Two commands are still deprecated and will be removed in a future release: + - `gcli vault list-files` + - `gcli vault migrate` + +### Removed +- None + +### CI/CD +- None + ## [0.4.0] - 2025-02-xx ### Changed - Old key files cannot be used directly anymore, they have to be migrated to the SQLite file database. You can use the following commands for that: diff --git a/Cargo.lock b/Cargo.lock index a9cd348674c8f8da078884faf7c47ebf434def92..0264e145c924b19a5494f9354fc7eb95f4789e00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2338,7 +2338,7 @@ dependencies = [ [[package]] name = "gcli" -version = "0.4.0" +version = "0.4.1" dependencies = [ "age", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ce66a64826afe4979ebd7c56b1bcda4a1eaf34b6..8600c6d5aed9758e0ebee3477db4869c18244377 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ license = "AGPL-3.0-only" name = "gcli" repository = "https://git.duniter.org/clients/rust/gcli-v2s" description = "A command-line interface for Duniter v2s uses" -version = "0.4.0" +version = "0.4.1" [dependencies] # subxt is main dependency diff --git a/doc/config.md b/doc/config.md index 387427212b5e547c4bfac0008dfe7a0f9ff3c78e..111baeae7f777c7d1a3ca31168d85aca2e51b044 100644 --- a/doc/config.md +++ b/doc/config.md @@ -5,7 +5,7 @@ Some Äžcli commands require to have an address configured (for example to get ac ```sh # save Alice address to config file -gcli -S predefined -s Alice config save +gcli -S predefined -s Alice -c sr25519 config save # show config gcli config show @@ -46,10 +46,9 @@ Here is an example that: * makes a transfer from selected Address ```sh -# add a new secret to the vault using substrate uri -gcli vault import +# add a new secret to the vault using substrate uri and crypto scheme sr25519 +gcli vault import -c sr25519 # [stdout] -# gcli vault import # Substrate URI can be a mnemonic or a mini-secret ('0x' prefixed seed) together with optional derivation path # > Substrate URI: ******** # @@ -159,8 +158,9 @@ gcli --help # Options: # -i, --indexer <INDEXER> Overwrite indexer endpoint # --no-indexer Do not use indexer -# -s, --secret <SECRET> Secret key or BIP39 mnemonic (only used when secret format is compatible) (eventually followed by derivation path) # -S, --secret-format <SECRET_FORMAT> Secret key format (seed, substrate, g1v1) +# -s, --secret <SECRET> Secret key or BIP39 mnemonic (only used when secret format is compatible) (eventually followed by derivation path) +# -c, --crypto-scheme <CRYPTO_SCHEME> Crypto scheme to use (sr25519, ed25519) [default: ed25519] # -a <ADDRESS> SS58 Address # -v <NAME> Name of an SS58 Address in the vault # -u, --url <URL> Overwrite duniter websocket RPC endpoint @@ -203,20 +203,32 @@ gcli vault derive --help # [stdout] # Add a derivation to an existing SS58 Address. # -# Only "sr25519" crypto scheme is supported for derivations. -# +# Both "sr25519" and "ed25519" crypto schemes are supported +# # Use command `vault list base` to see available <Base> account and their crypto scheme -# And then use command 'vault list for' to find all accounts linked to that <Base> account. +# And then use command 'vault list for' to find all accounts linked to that <Base> account # -# Usage: gcli vault derive <-a <ADDRESS>|-v <NAME>> +# Usage: gcli vault derive [OPTIONS] <-a <ADDRESS>|-v <VAULT_NAME>> # # Options: # -a <ADDRESS> # SS58 Address # -# -v <NAME> +# -v <VAULT_NAME> # Name of an SS58 Address in the vault # +# -d, --derivation-path <DERIVATION_PATH> +# Derivation path (non-interactive mode) +# +# -p, --password <PASSWORD> +# Password to decrypt the <Base> account key (non-interactive mode) +# +# --no-password +# Use empty password to decrypt the <Base> account key (non-interactive mode) +# +# -n, --name <NAME> +# Name for the wallet entry (non-interactive mode) - "" empty string will be considered as None +# # -h, --help # Print help (see a summary with '-h') ``` \ No newline at end of file diff --git a/src/commands/identity.rs b/src/commands/identity.rs index 0cc8f4f1cd765783a93b52df1cad89d04be6d24a..7ae600e0adb29078dfb303f937505edcc7374596 100644 --- a/src/commands/identity.rs +++ b/src/commands/identity.rs @@ -1,5 +1,6 @@ use crate::*; +use crate::commands::vault::retrieve_account_tree_node_for_name; use crate::{ commands::revocation::generate_revoc_doc, runtime::runtime_types::{ @@ -62,27 +63,70 @@ pub enum Subcommand { GenRevocDoc, /// Display member count MemberCount, - /// Link an account to the identity - LinkAccount { - /// Secret key format (seed, substrate) - #[clap(short = 'S', long, default_value = SecretFormat::Substrate)] - secret_format: SecretFormat, - /// Secret of account to link - /// most likely different from the one owning the identity - #[clap(short, long)] - secret: Option<String>, - }, - /// Migrate identity to another account - /// Change Owner Key - ChangeOwnerKey { - /// Secret key format (seed, substrate) - #[clap(short = 'S', long, default_value = SecretFormat::Substrate)] - secret_format: SecretFormat, - /// Secret of account to link - /// most likely different from the one owning the identity - #[clap(short, long)] - secret: Option<String>, - }, + /// Link an account to the [target] identity + #[clap(long_about = "Link an account to the [target] identity.\n\ + \n\ + The target identity can be passed as argument using any of the suggested options.")] + LinkAccount(SecretProvider), + /// Migrate identity to another [target] account + #[clap(long_about = "Migrate identity to another [target] account.\n\ + \n\ + The target account can be passed as argument using any of the suggested options.")] + ChangeOwnerKey(SecretProvider), +} + +#[derive(clap::Args, Clone, Debug)] +pub struct SecretProvider { + /// SS58 Address of target vault account + #[clap(short, conflicts_with_all=["vault_name","secret_format", "secret", "crypto_scheme"])] + address: Option<AccountId>, + /// Name of target vault account + #[clap(short = 'v', conflicts_with_all=["secret_format", "secret", "crypto_scheme"])] + vault_name: Option<String>, + /// Secret key format of target account (seed, substrate) + #[clap(short = 'S', long)] + secret_format: Option<SecretFormat>, + /// Secret of target account + #[clap(short, long)] + secret: Option<String>, + /// Crypto scheme of target account (sr25519, ed25519) + #[clap(short = 'c', long, required = false, default_value = CryptoScheme::Ed25519)] + crypto_scheme: CryptoScheme, +} + +impl SecretProvider { + /// Analyses the SecretProvider data and tries to retrieve a keypair. + /// + /// Will potentially request a password to decrypt the secret in keystore if it was asked to use a vault account. + /// + /// Or it could request the `secret` if using `secret-format` without also providing the `secret`. + /// + /// Will return an error if no data was provided or if it encountered another issue in the process. + async fn get_keypair(&self, data: &Data) -> Result<KeyPair, GcliError> { + let key_pair = if let Some(address) = self.address.clone() { + commands::vault::fetch_vault_keypair_for_address(data, address).await? + } else if let Some(vault_name) = self.vault_name.clone() { + let account_tree_node = + retrieve_account_tree_node_for_name(data.connect_db(), &vault_name).await?; + let address = account_tree_node.borrow().account.address.0.clone(); + + commands::vault::fetch_vault_keypair_for_address(data, address).await? + } else if let Some(secret_format) = self.secret_format { + let keypair = get_keypair(secret_format, self.secret.as_deref(), self.crypto_scheme)?; + println!( + "target address:'{}' (using crypto-scheme:{})", + keypair.address(), + <&'static str>::from(self.crypto_scheme) + ); + keypair + } else { + return Err(GcliError::Input( + "One of `address`/`vault_name`/`secret_format`(and optional `secret` & `crypto_scheme`) must be provided".to_string(), + )); + }; + + Ok(key_pair) + } } /// handle identity commands @@ -150,23 +194,21 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE .unwrap() ) } - Subcommand::LinkAccount { - secret_format, - secret, - } => { - let keypair = get_keypair(secret_format, secret.as_deref())?; - let address = keypair.address(); - data = data.fetch_idty_index().await?; // idty index required for payload - link_account(&data, address, keypair).await?; + Subcommand::LinkAccount(secret_provider) => { + let target_keypair = secret_provider.get_keypair(&data).await?; + + println!("Trying to make the link"); + let address = target_keypair.address(); + let data = data.fetch_idty_index().await?; // idty index required for payload + link_account(&data, address, target_keypair).await?; } - Subcommand::ChangeOwnerKey { - secret_format, - secret, - } => { - let keypair = get_keypair(secret_format, secret.as_deref())?; - let address = keypair.address(); - data = data.fetch_idty_index().await?; // idty index required for payload - change_owner_key(&data, address, keypair).await?; + Subcommand::ChangeOwnerKey(secret_provider) => { + let target_keypair = secret_provider.get_keypair(&data).await?; + + println!("Trying to change owner key"); + let address = target_keypair.address(); + let data = data.fetch_idty_index().await?; // idty index required for payload + change_owner_key(&data, address, target_keypair).await?; } }; diff --git a/src/commands/transfer.rs b/src/commands/transfer.rs index 16f2e5e003f0ec410ac6d06064815c711c6eac92..1a977179979623d2b2bc910c77988465f94c9586 100644 --- a/src/commands/transfer.rs +++ b/src/commands/transfer.rs @@ -1,6 +1,6 @@ use crate::*; -#[cfg(any(feature = "dev", feature = "gdev"))] // find how to get runtime calls +#[cfg(feature = "gdev")] // find how to get runtime calls type Call = runtime::runtime_types::gdev_runtime::RuntimeCall; type BalancesCall = runtime::runtime_types::pallet_balances::pallet::Call; diff --git a/src/commands/vault.rs b/src/commands/vault.rs index 8d6de10f65b0529b449934d4e174cb7c25348151..b46606e1d2171dbe2d16b5ee98d0658b57ae38e1 100644 --- a/src/commands/vault.rs +++ b/src/commands/vault.rs @@ -1,13 +1,15 @@ 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}; +use crate::keys::seed_from_cesium; use crate::*; use age::secrecy::Secret; use sea_orm::ActiveValue::Set; +use sea_orm::ModelTrait; use sea_orm::{ConnectionTrait, TransactionTrait}; -use sea_orm::{DbErr, ModelTrait}; use sp_core::crypto::AddressUri; use std::cell::RefCell; use std::io::{Read, Write}; @@ -41,19 +43,63 @@ pub enum Subcommand { /// Secret key format (substrate, seed, g1v1) #[clap(short = 'S', long, required = false, default_value = SecretFormat::Substrate)] secret_format: SecretFormat, + + /// Crypto scheme to use (sr25519, ed25519) + #[clap(short = 'c', long, required = false, default_value = CryptoScheme::Ed25519)] + crypto_scheme: CryptoScheme, + + /// Substrate URI to import (non-interactive mode) + #[clap(short = 'u', long, required = false)] + uri: Option<String>, + + /// G1v1 ID (non-interactive mode for g1v1 format) + #[clap(long, required = false)] + g1v1_id: Option<String>, + + /// G1v1 password (non-interactive mode for g1v1 format) + #[clap(long, required = false)] + g1v1_password: Option<String>, + + /// Password for encrypting the key (non-interactive mode) + #[clap(short = 'p', long, required = false, conflicts_with_all=["no_password"])] + password: Option<String>, + + /// Use empty password for encrypting the key (non-interactive mode) + #[clap(long, required = false)] + no_password: bool, + + /// Name for the wallet entry (non-interactive mode) - "" empty string will be considered as None + #[clap(short = 'n', long, required = false)] + name: Option<String>, }, /// Add a derivation to an existing SS58 Address #[clap(long_about = "Add a derivation to an existing SS58 Address.\n\ \n\ - Only \"sr25519\" crypto scheme is supported for derivations.\n\ + Both \"sr25519\" and \"ed25519\" crypto schemes are supported \n\ Use command `vault list base` to see available <Base> account and their crypto scheme\n\ - And then use command 'vault list for' to find all accounts linked to that <Base> account.")] + And then use command 'vault list for' to find all accounts linked to that <Base> account")] #[clap(alias = "deriv")] #[clap(alias = "derivation")] Derive { #[clap(flatten)] address_or_vault_name: AddressOrVaultNameGroup, + + /// Derivation path (non-interactive mode) + #[clap(short = 'd', long, required = false)] + derivation_path: Option<String>, + + /// Password to decrypt the <Base> account key (non-interactive mode) + #[clap(short = 'p', long, required = false, requires = "derivation_path", conflicts_with_all=["no_password"])] + password: Option<String>, + + /// Use empty password to decrypt the <Base> account key (non-interactive mode) + #[clap(long, required = false, requires = "derivation_path")] + no_password: bool, + + /// Name for the wallet entry (non-interactive mode) - "" empty string will be considered as None + #[clap(short = 'n', long, required = false, requires = "derivation_path")] + name: Option<String>, }, /// Give a meaningful name to an SS58 Address in the vault Rename { @@ -68,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, @@ -87,18 +133,38 @@ pub enum Subcommand { Where, } -#[derive(Clone, Default, Debug, clap::Parser)] +/// List subcommands +#[derive(Clone, Debug, clap::Subcommand)] pub enum ListChoice { - /// List all <Base> SS58 Addresses and their linked derivations in the vault - #[default] - All, - /// List <Base> and Derivation SS58 Addresses linked to the selected one + /// List all accounts + #[clap(alias = "a")] + All { + /// Show G1v1 public key for ed25519 keys + #[clap(long)] + show_g1v1: bool, + }, + /// List only base accounts + #[clap(alias = "b")] + Base { + /// Show G1v1 public key for ed25519 keys + #[clap(long)] + show_g1v1: bool, + }, + /// List accounts for a specific address + #[clap(alias = "f")] For { #[clap(flatten)] address_or_vault_name: AddressOrVaultNameGroup, + /// Show G1v1 public key for ed25519 keys + #[clap(long)] + show_g1v1: bool, }, - /// List all <Base> SS58 Addresses in the vault - Base, +} + +impl Default for ListChoice { + fn default() -> Self { + ListChoice::All { show_g1v1: false } + } } #[derive(Debug, clap::Args, Clone)] @@ -109,7 +175,7 @@ pub struct AddressOrVaultNameGroup { address: Option<AccountId>, /// Name of an SS58 Address in the vault #[clap(short = 'v')] - name: Option<String>, + vault_name: Option<String>, } pub struct VaultDataToImport { @@ -151,26 +217,33 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE // match subcommand match command { Subcommand::List(choice) => match choice { - ListChoice::All => { + ListChoice::All { show_g1v1 } => { let all_account_tree_node_hierarchies = vault_account::fetch_all_base_account_tree_node_hierarchies(db).await?; - let table = - display::compute_vault_accounts_table(&all_account_tree_node_hierarchies)?; + + let table = display::compute_vault_accounts_table_with_g1v1( + &all_account_tree_node_hierarchies, + show_g1v1, + )?; println!("available SS58 Addresses:"); println!("{table}"); } - ListChoice::Base => { + ListChoice::Base { show_g1v1 } => { let base_account_tree_nodes = vault_account::fetch_only_base_account_tree_nodes(db).await?; - let table = display::compute_vault_accounts_table(&base_account_tree_nodes)?; + let table = display::compute_vault_accounts_table_with_g1v1( + &base_account_tree_nodes, + show_g1v1, + )?; println!("available <Base> SS58 Addresses:"); println!("{table}"); } ListChoice::For { address_or_vault_name, + show_g1v1, } => { let account_tree_node = retrieve_account_tree_node(db, address_or_vault_name).await?; @@ -178,7 +251,10 @@ 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); - let table = display::compute_vault_accounts_table(&[base_account_tree_node])?; + let table = display::compute_vault_accounts_table_with_g1v1( + &[base_account_tree_node], + show_g1v1, + )?; println!( "available SS58 Addresses linked to {}:", @@ -215,28 +291,93 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let mnemonic = bip39::Mnemonic::generate(12).unwrap(); println!("{mnemonic}"); } - Subcommand::Import { secret_format } => { - let vault_data_for_import = - prompt_secret_and_compute_vault_data_to_import(secret_format)?; + Subcommand::Import { + secret_format, + crypto_scheme, + uri, + g1v1_id, + g1v1_password, + password, + no_password, + name, + } => { + let vault_data_for_import = if let Some(uri_str) = uri { + // Non-interactive mode with provided URI + if secret_format != SecretFormat::Substrate { + return Err(GcliError::Input(format!( + "URI can only be provided directly with secret_format=substrate, got: {:?}", + secret_format + ))); + } + + // Create keypair from provided URI + let key_pair = compute_keypair(crypto_scheme, &uri_str)?; + + VaultDataToImport { + secret_format, + secret_suri: uri_str, + key_pair, + } + } else if let (Some(id), Some(pwd)) = (&g1v1_id, &g1v1_password) { + // Non-interactive mode with provided G1v1 ID and password + if secret_format != SecretFormat::G1v1 { + return Err(GcliError::Input(format!( + "G1v1 ID and password can only be provided directly with secret_format=g1v1, got: {:?}", + secret_format + ))); + } + + // Create keypair from provided G1v1 ID and password + let seed = seed_from_cesium(id, pwd); + let secret_suri = format!("0x{}", hex::encode(seed)); + + // G1v1 always uses Ed25519 + let key_pair = compute_keypair(CryptoScheme::Ed25519, &secret_suri)?; + + VaultDataToImport { + secret_format, + secret_suri, + key_pair, + } + } else { + // Interactive mode + prompt_secret_and_compute_vault_data_to_import(secret_format, crypto_scheme)? + }; //Extra check for SecretFormat::G1v1 (old cesium) - showing the G1v1 cesium public key for confirmation 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)? ); - let confirmed = inputs::confirm_action("Is it the correct one (if not, you should try again to input G1v1 id/password) ?".to_string())?; - if !confirmed { - return Ok(()); + + // Skip confirmation in non-interactive mode + let is_non_interactive_g1v1 = g1v1_id.is_some() && g1v1_password.is_some(); + if !is_non_interactive_g1v1 { + let confirmed = inputs::confirm_action("Is it the correct one (if not, you should try again to input G1v1 id/password) ?".to_string())?; + if !confirmed { + return Ok(()); + } } } let txn = db.begin().await?; - println!(); - let _account = - create_base_account_for_vault_data_to_import(&txn, &vault_data_for_import, None) - .await?; + // Handle password in non-interactive mode + let provided_password = if no_password { + Some(String::new()) // Empty password + } else { + password + }; + + let _account = create_base_account_for_vault_data_to_import( + &txn, + &vault_data_for_import, + provided_password.as_ref(), + Some(crypto_scheme), + name, + ) + .await?; txn.commit().await?; @@ -244,6 +385,10 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE } Subcommand::Derive { address_or_vault_name, + derivation_path, + password, + no_password, + name, } => { let account_tree_node_to_derive = retrieve_account_tree_node(db, address_or_vault_name).await?; @@ -255,25 +400,6 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let base_account = &base_account_tree_node.borrow().account.clone(); - if base_account.crypto_scheme.is_none() { - return Err(GcliError::DatabaseError(DbErr::Custom(format!("Crypto scheme is not set for the base account:{base_account} - should never happen")))); - } - - if let Some(crypto_scheme) = base_account.crypto_scheme { - if CryptoScheme::from(crypto_scheme) == CryptoScheme::Ed25519 { - println!( - "Only \"{}\" crypto scheme is supported for derivations.", - Into::<&str>::into(CryptoScheme::Sr25519), - ); - println!(); - println!( - "Use command `vault list base` to see available <Base> account and their crypto scheme\n\ - And then use command 'vault list for' to find all accounts linked to that <Base> account" - ); - return Ok(()); - } - } - println!("Adding derivation to: {account_to_derive}"); let base_parent_hierarchy_account_tree_node_to_derive = @@ -293,8 +419,15 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE println!("The linked <Base> account is {base_account}"); - println!("Enter password to decrypt the <Base> account key"); - let password = inputs::prompt_password()?; + // Handle password from non-interactive mode or ask for it + let password = if no_password { + String::new() + } else if let Some(password) = password { + password + } else { + println!("Enter password to decrypt the <Base> account key"); + inputs::prompt_password()? + }; let account_to_derive_secret_suri = vault_account::compute_suri_account_tree_node( &account_tree_node_to_derive, @@ -302,13 +435,24 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE )?; println!(); - let derivation_path = inputs::prompt_vault_derivation_path()?; + + // Handle derivation_path from non-interactive mode or ask for it + let derivation_path = if let Some(derivation_path) = derivation_path { + validate_derivation_path(derivation_path.clone())?; + derivation_path + } else { + inputs::prompt_vault_derivation_path()? + }; let derivation_secret_suri = format!("{account_to_derive_secret_suri}{derivation_path}"); - let derivation_keypair = - compute_keypair(CryptoScheme::Sr25519, &derivation_secret_suri)?; + let crypto_scheme = base_account + .crypto_scheme + .map(CryptoScheme::from) + .unwrap_or(CryptoScheme::Ed25519); // Fallback to Ed25519 if not defined (should never happen) + + let derivation_keypair = compute_keypair(crypto_scheme, &derivation_secret_suri)?; let derivation_address: String = derivation_keypair.address().to_string(); @@ -320,6 +464,7 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE &derivation_address, &derivation_path, &account_to_derive.address.to_string(), + name, ) .await?; @@ -409,18 +554,67 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE Subcommand::Inspect { address_or_vault_name, } => { - let account_tree_node_to_derive = + let account_tree_node_to_inspect = retrieve_account_tree_node(db, address_or_vault_name).await?; + let base_account_tree_node = + vault_account::get_base_account_tree_node(&account_tree_node_to_inspect); + + 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}"); + } + println!("Enter password to decrypt the <Base> account key"); let password = inputs::prompt_password()?; let account_to_derive_secret_suri = vault_account::compute_suri_account_tree_node( - &account_tree_node_to_derive, + &account_tree_node_to_inspect, password, )?; + println!("Substrate URI: '{account_to_derive_secret_suri}'"); + + let crypto_scheme: CryptoScheme = base_account_tree_node + .borrow() + .account + .crypto_scheme + .ok_or(GcliError::Logic( + "Base account without crypto_scheme".to_string(), + ))? + .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)); + } + } - println!("Substrate URI: '{account_to_derive_secret_suri}'") + 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"); @@ -464,10 +658,13 @@ pub async fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliE let txn = db.begin().await?; + // Old key files were in Sr25519 format (and had the Address associated to that scheme) let account = create_base_account_for_vault_data_to_import( &txn, &vault_data_to_import, Some(&vault_data_from_file.password), + Some(CryptoScheme::Sr25519), + None, ) .await; @@ -539,12 +736,27 @@ pub fn parse_prefix_and_derivation_path_from_suri( Ok((address_uri.phrase.map(|s| s.to_string()), full_path)) } -fn map_secret_format_to_crypto_scheme(secret_format: SecretFormat) -> CryptoScheme { - match secret_format { - SecretFormat::Seed => CryptoScheme::Sr25519, - SecretFormat::Substrate => CryptoScheme::Sr25519, - SecretFormat::Predefined => CryptoScheme::Sr25519, - SecretFormat::G1v1 => CryptoScheme::Ed25519, +fn map_secret_format_to_crypto_scheme( + secret_format: SecretFormat, + override_crypto_scheme: Option<CryptoScheme>, +) -> CryptoScheme { + // If a crypto_scheme is explicitly specified, use it except for G1v1 which must always use Ed25519 + if let Some(scheme) = override_crypto_scheme { + if secret_format == SecretFormat::G1v1 { + // G1v1 must always use Ed25519 + CryptoScheme::Ed25519 + } else { + scheme + } + } else { + // Default behavior if no crypto_scheme is specified + match secret_format { + // All formats use Ed25519 by default + SecretFormat::Seed => CryptoScheme::Ed25519, + SecretFormat::Substrate => CryptoScheme::Ed25519, + SecretFormat::Predefined => CryptoScheme::Ed25519, + SecretFormat::G1v1 => CryptoScheme::Ed25519, + } } } @@ -593,7 +805,7 @@ pub async fn retrieve_account_tree_node<C>( where C: ConnectionTrait, { - let account_tree_node = if let Some(name_input) = &address_or_vault_name.name { + let account_tree_node = if let Some(name_input) = &address_or_vault_name.vault_name { retrieve_account_tree_node_for_name(db, name_input).await? } else if let Some(address) = &address_or_vault_name.address { let base_account_tree_node = @@ -675,13 +887,14 @@ where fn create_vault_data_to_import<F, P>( secret_format: SecretFormat, + crypto_scheme: CryptoScheme, prompt_fn: F, ) -> Result<VaultDataToImport, GcliError> where - F: Fn() -> (String, P), + F: Fn(CryptoScheme) -> (String, P), P: Into<KeyPair>, { - let (secret, pair) = prompt_fn(); + let (secret, pair) = prompt_fn(crypto_scheme); let key_pair = pair.into(); Ok(VaultDataToImport { secret_format, @@ -692,20 +905,32 @@ where fn prompt_secret_and_compute_vault_data_to_import( secret_format: SecretFormat, + crypto_scheme: CryptoScheme, ) -> Result<VaultDataToImport, GcliError> { match secret_format { - SecretFormat::Substrate => { - create_vault_data_to_import(secret_format, prompt_secret_substrate_and_compute_keypair) - } - SecretFormat::Seed => { - create_vault_data_to_import(secret_format, prompt_seed_and_compute_keypair) - } + SecretFormat::Substrate => create_vault_data_to_import( + secret_format, + crypto_scheme, + prompt_secret_substrate_and_compute_keypair, + ), + SecretFormat::Seed => create_vault_data_to_import( + secret_format, + crypto_scheme, + prompt_seed_and_compute_keypair, + ), SecretFormat::G1v1 => { - create_vault_data_to_import(secret_format, prompt_secret_cesium_and_compute_keypair) - } - SecretFormat::Predefined => { - create_vault_data_to_import(secret_format, prompt_predefined_and_compute_keypair) + // G1v1 always uses Ed25519, ignore crypto_scheme + create_vault_data_to_import( + secret_format, + CryptoScheme::Ed25519, + prompt_secret_cesium_and_compute_keypair, + ) } + SecretFormat::Predefined => create_vault_data_to_import( + secret_format, + crypto_scheme, + prompt_predefined_and_compute_keypair, + ), } } @@ -719,7 +944,9 @@ fn prompt_secret_and_compute_vault_data_to_import( pub async fn create_base_account_for_vault_data_to_import<C>( db_tx: &C, vault_data: &VaultDataToImport, - password: Option<&String>, + password_opt: Option<&String>, + crypto_scheme: Option<CryptoScheme>, + name_opt: Option<String>, ) -> Result<vault_account::Model, GcliError> where C: ConnectionTrait, @@ -728,9 +955,10 @@ where println!("Trying to import for SS58 address :'{}'", address_to_import); println!(); - let vault_account = if let Some(existing_vault_account) = + if let Some(existing_vault_account) = vault_account::find_by_id(db_tx, &DbAccountId::from(address_to_import.clone())).await? { + // Existing account if existing_vault_account.is_base_account() { println!("You are trying to add {address_to_import} as a <Base> account while it already exists as a <Base> account."); println!(); @@ -776,49 +1004,66 @@ where let result = inputs::select_action("Your choice?", vec!["1", "2"])?; match result { "2" => { + let password = match password_opt { + Some(password) => password.clone(), + None => inputs::prompt_password_query("Enter password to encrypt the key: ")?, + }; + let encrypted_suri = compute_encrypted_suri(password, vault_data.secret_suri.clone())?; - println!( - "(Optional) Enter a name for the vault entry (leave empty to remove the name)" - ); - let name = inputs::prompt_vault_name_and_check_availability( - db_tx, - existing_vault_account.name.as_ref(), - ) - .await?; + let name = if let Some(name) = name_opt { + validate_vault_name(&name)?; + trim_and_reduce_empty_as_none(name) + } else { + println!("(Optional) Enter a name for the vault entry (leave empty to remove the name)"); + inputs::prompt_vault_name_and_check_availability( + db_tx, + existing_vault_account.name.as_ref(), + ) + .await? + }; // Since links are made based on address / parent(address) we can just edit the existing entry and it should be fine let mut vault_account: ActiveModel = existing_vault_account.into(); vault_account.path = Set(None); vault_account.parent = Set(None); vault_account.crypto_scheme = Set(Some( - map_secret_format_to_crypto_scheme(vault_data.secret_format).into(), + map_secret_format_to_crypto_scheme(vault_data.secret_format, crypto_scheme) + .into(), )); vault_account.encrypted_suri = Set(Some(encrypted_suri)); - vault_account.name = Set(name.clone()); + vault_account.name = Set(name); let updated_vault_account = vault_account::update_account(db_tx, vault_account).await?; println!("Updating vault account {updated_vault_account}"); - updated_vault_account - } - _ => { - return Err(GcliError::Input("import canceled".into())); + Ok(updated_vault_account) } + _ => Err(GcliError::Input("import canceled".into())), } } else { //New entry let secret_format = vault_data.secret_format; + let password = match password_opt { + Some(password) => password.clone(), + None => inputs::prompt_password_query("Enter password to encrypt the key: ")?, + }; + let encrypted_suri = compute_encrypted_suri(password, vault_data.secret_suri.clone())?; - println!("(Optional) Enter a name for the vault entry"); - let name = inputs::prompt_vault_name_and_check_availability(db_tx, None).await?; + let name = if let Some(name) = name_opt { + validate_vault_name(&name)?; + trim_and_reduce_empty_as_none(name) + } else { + println!("(Optional) Enter a name for the vault entry"); + inputs::prompt_vault_name_and_check_availability(db_tx, None).await? + }; - let crypto_scheme = map_secret_format_to_crypto_scheme(secret_format); + let crypto_scheme = map_secret_format_to_crypto_scheme(secret_format, crypto_scheme); - let base_account = vault_account::create_base_account( + let account = vault_account::create_base_account( db_tx, &address_to_import, name.as_ref(), @@ -826,12 +1071,10 @@ where encrypted_suri, ) .await?; - println!("Creating <Base> account {base_account}"); - base_account - }; - - Ok(vault_account) + println!("Creating <Base> account {account}"); + Ok(account) + } } /// Creates a `derivation` vault account for data provided and returns it @@ -846,6 +1089,7 @@ pub async fn create_derivation_account<C>( derivation_address: &String, derivation_path: &String, parent_address: &String, + name_opt: Option<String>, ) -> Result<vault_account::Model, GcliError> where C: ConnectionTrait, @@ -910,14 +1154,17 @@ where let result = inputs::select_action("Your choice?", vec!["1", "2"])?; match result { "2" => { - println!( - "(Optional) Enter a name for the vault entry (leave empty to remove the name)" - ); - let name = inputs::prompt_vault_name_and_check_availability( - db_tx, - existing_vault_account.name.as_ref(), - ) - .await?; + let name = if let Some(name) = name_opt { + validate_vault_name(&name)?; + trim_and_reduce_empty_as_none(name) + } else { + println!("(Optional) Enter a name for the vault entry (leave empty to remove the name)"); + inputs::prompt_vault_name_and_check_availability( + db_tx, + existing_vault_account.name.as_ref(), + ) + .await? + }; // Since links are made based on address / parent(address) we can just edit the existing entry and it should be fine let mut vault_account: ActiveModel = existing_vault_account.into(); @@ -937,8 +1184,13 @@ where } } } else { - println!("(Optional) Enter a name for the vault entry"); - let name = inputs::prompt_vault_name_and_check_availability(db_tx, None).await?; + let name = if let Some(name) = name_opt { + validate_vault_name(&name)?; + trim_and_reduce_empty_as_none(name) + } else { + println!("(Optional) Enter a name for the vault entry"); + inputs::prompt_vault_name_and_check_availability(db_tx, None).await? + }; let derivation = vault_account::create_derivation_account( db_tx, @@ -956,20 +1208,9 @@ where Ok(vault_account) } -/// Function will ask for password if not present and compute the encrypted suri -fn compute_encrypted_suri( - password: Option<&String>, - secret_suri: String, -) -> Result<Vec<u8>, GcliError> { - let password = match password.cloned() { - Some(password) => password, - _ => { - println!("Enter password to protect the key"); - inputs::prompt_password_confirm()? - } - }; - - Ok(encrypt(secret_suri.as_bytes(), password).map_err(|e| anyhow!(e))?) +/// Function will compute the encrypted suri +fn compute_encrypted_suri(password: String, secret_suri: String) -> Result<Vec<u8>, GcliError> { + encrypt(secret_suri.as_bytes(), password).map_err(|e| GcliError::Input(e.to_string())) } fn get_vault_key_path(data: &Data, vault_filename: &str) -> PathBuf { @@ -986,7 +1227,20 @@ fn find_substrate_vault_key_file(data: &Data, address: &str) -> Result<Option<Pa Ok(None) } -/// try to get secret in keystore, prompt for the password and compute the keypair +/// Gets secret in keystore, prompt for the password and compute the keypair associated to `address` +/// +/// Returns an error if no entry was found in the keystore or if another error occurred during the process +pub async fn fetch_vault_keypair_for_address( + data: &Data, + address: AccountId, +) -> Result<KeyPair, GcliError> { + println!("Trying to retrieve key pair for address:'{address}'"); + try_fetch_key_pair(data, address) + .await? + .ok_or_else(|| GcliError::Input("vault account not found".to_string())) +} + +/// try to get secret in keystore, prompt for the password and compute the keypair associated to `address` pub async fn try_fetch_key_pair( data: &Data, address: AccountId, diff --git a/src/commands/vault/display.rs b/src/commands/vault/display.rs index 021a84fffd139e85d99baa76a6c7be3f466a695f..251edfe7e69b0d6229372de3af52f2a59a16cad3 100644 --- a/src/commands/vault/display.rs +++ b/src/commands/vault/display.rs @@ -25,34 +25,49 @@ pub fn compute_vault_key_files_table(vault_key_addresses: &[String]) -> Result<T pub fn compute_vault_accounts_table( account_tree_nodes: &[Rc<RefCell<AccountTreeNode>>], +) -> Result<Table, GcliError> { + // Calling the new function with show_g1v1 = true to maintain compatibility + compute_vault_accounts_table_with_g1v1(account_tree_nodes, true) +} + +pub fn compute_vault_accounts_table_with_g1v1( + account_tree_nodes: &[Rc<RefCell<AccountTreeNode>>], + show_g1v1: bool, ) -> Result<Table, GcliError> { let mut table = Table::new(); table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY); + + // Prepare header based on options table.set_header(vec![ - "SS58 Address/G1v1 public key", + if show_g1v1 { + "SS58 Address/G1v1 public key" + } else { + "SS58 Address" + }, "Crypto", "Path", "Name", ]); for account_tree_node in account_tree_nodes { - let _ = add_account_tree_node_to_table(&mut table, account_tree_node); + let _ = add_account_tree_node_to_table_with_g1v1(&mut table, account_tree_node, show_g1v1); } Ok(table) } -fn add_account_tree_node_to_table( +fn add_account_tree_node_to_table_with_g1v1( table: &mut Table, account_tree_node: &Rc<RefCell<AccountTreeNode>>, + show_g1v1: bool, ) -> Result<(), GcliError> { - let rows = compute_vault_accounts_row(account_tree_node)?; + let rows = compute_vault_accounts_row_with_g1v1(account_tree_node, show_g1v1)?; rows.iter().for_each(|row| { table.add_row(row.clone()); }); for child in &account_tree_node.borrow().children { - let _ = add_account_tree_node_to_table(table, child); + let _ = add_account_tree_node_to_table_with_g1v1(table, child, show_g1v1); } Ok(()) @@ -60,9 +75,10 @@ fn add_account_tree_node_to_table( /// Computes one or more row of the table for selected account_tree_node /// -/// For ed25519 keys, will display over 2 rows to also show the base 58 G1v1 public key -pub fn compute_vault_accounts_row( +/// For ed25519 keys, will display over 2 rows to also show the base 58 G1v1 public key if show_g1v1 is true +pub fn compute_vault_accounts_row_with_g1v1( account_tree_node: &Rc<RefCell<AccountTreeNode>>, + show_g1v1: bool, ) -> Result<Vec<Vec<Cell>>, GcliError> { let empty_string = "".to_string(); @@ -93,9 +109,11 @@ pub fn compute_vault_accounts_row( (path, empty_string.clone()) } else { let crypto_scheme = CryptoScheme::from(account_tree_node.account.crypto_scheme.unwrap()); + let crypto_scheme_str: &str = crypto_scheme.into(); - // Adding 2nd row for G1v1 public key - if CryptoScheme::Ed25519 == crypto_scheme { + // Add a second line for the G1v1 public key only if show_g1v1 is true and it's an Ed25519 key + let is_ed25519 = crypto_scheme == CryptoScheme::Ed25519; + if show_g1v1 && is_ed25519 { rows.push(vec![Cell::new(format!( "â”” G1v1: {}", cesium::compute_g1v1_public_key_from_ed25519_account_id( @@ -104,14 +122,13 @@ pub fn compute_vault_accounts_row( ))]); } - let crypto_scheme_str: &str = crypto_scheme.into(); ( format!("<{}>", account_tree_node.account.account_type()), crypto_scheme_str.to_string(), ) }; - // Adding 1st row + // Add the first line rows.insert( 0, vec![ @@ -128,12 +145,15 @@ pub fn compute_vault_accounts_row( #[cfg(test)] mod tests { mod vault_accounts_table_tests { - use crate::commands::vault::display::compute_vault_accounts_table; + use crate::commands::vault::display::{ + compute_vault_accounts_table, compute_vault_accounts_table_with_g1v1, + }; use crate::entities::vault_account::tests::account_tree_node_tests::{ mother_account_tree_node, mother_g1v1_account_tree_node, }; use indoc::indoc; + // Tests for compute_vault_accounts_table (old function) #[test] fn test_compute_vault_accounts_table_empty() { let table = compute_vault_accounts_table(&[]).unwrap(); @@ -191,5 +211,110 @@ mod tests { assert_eq!(table.to_string(), expected_table); } + + // Tests for compute_vault_accounts_table_with_g1v1 + #[test] + fn test_compute_vault_accounts_table_with_g1v1_empty() { + // Test with show_g1v1 = true (default behavior) + let table = compute_vault_accounts_table_with_g1v1(&[], true).unwrap(); + let expected_table_with_g1v1 = indoc! {r#" + ┌─────────────────────────────────────────────────────┠+ │ SS58 Address/G1v1 public key Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + └─────────────────────────────────────────────────────┘"# + }; + + assert_eq!(table.to_string(), expected_table_with_g1v1); + + // Test with show_g1v1 = false + let table = compute_vault_accounts_table_with_g1v1(&[], false).unwrap(); + let expected_table_without_g1v1 = indoc! {r#" + ┌─────────────────────────────────────┠+ │ SS58 Address Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + └─────────────────────────────────────┘"# + }; + + assert_eq!(table.to_string(), expected_table_without_g1v1); + } + + #[test] + fn test_compute_vault_accounts_table_with_g1v1() { + let account_tree_node = mother_account_tree_node(); + let g1v1_account_tree_node = mother_g1v1_account_tree_node(); + let account_tree_nodes = vec![account_tree_node, g1v1_account_tree_node]; + + // Test with show_g1v1 = true (default behavior) + let table_with_g1v1 = + compute_vault_accounts_table_with_g1v1(&account_tree_nodes, true).unwrap(); + let expected_table_with_g1v1 = indoc! {r#" + ┌──────────────────────────────────────────────────────────────────────────────────────────┠+ │ SS58 Address/G1v1 public key Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + │ 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV sr25519 <Base> Mother │ + │ ├ 5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH //0 Child 1 │ + │ │ ├ 5Fh5PLQNt1xuEXm71dfDtQdnwceSew4oHewWBLsWAkKspV7d //0 Grandchild 1 │ + │ ├ 5GBNeWRhZc2jXu7D55rBimKYDk8PGk8itRYFTPfC8RJLKG5o //1 <Mother//1> │ + │ │ ├ 5CvdJuB9HLXSi5FS9LW57cyHF13iCv5HDimo2C45KxnxriCT //1 <Mother//1//1> │ + │ 5ET2jhgJFoNQUpgfdSkdwftK8DKWdqZ1FKm5GKWdPfMWhPr4 ed25519 <Base> MotherG1v1 │ + │ â”” G1v1: 86pW1doyJPVH3jeDPZNQa1UZFBo5zcdvHERcaeE758W7 │ + └──────────────────────────────────────────────────────────────────────────────────────────┘"# + }; + + assert_eq!(table_with_g1v1.to_string(), expected_table_with_g1v1); + + // Test with show_g1v1 = false + let table_without_g1v1 = + compute_vault_accounts_table_with_g1v1(&account_tree_nodes, false).unwrap(); + let expected_table_without_g1v1 = indoc! {r#" + ┌──────────────────────────────────────────────────────────────────────────────────────────┠+ │ SS58 Address Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + │ 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV sr25519 <Base> Mother │ + │ ├ 5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH //0 Child 1 │ + │ │ ├ 5Fh5PLQNt1xuEXm71dfDtQdnwceSew4oHewWBLsWAkKspV7d //0 Grandchild 1 │ + │ ├ 5GBNeWRhZc2jXu7D55rBimKYDk8PGk8itRYFTPfC8RJLKG5o //1 <Mother//1> │ + │ │ ├ 5CvdJuB9HLXSi5FS9LW57cyHF13iCv5HDimo2C45KxnxriCT //1 <Mother//1//1> │ + │ 5ET2jhgJFoNQUpgfdSkdwftK8DKWdqZ1FKm5GKWdPfMWhPr4 ed25519 <Base> MotherG1v1 │ + └──────────────────────────────────────────────────────────────────────────────────────────┘"# + }; + + assert_eq!(table_without_g1v1.to_string(), expected_table_without_g1v1); + } + + #[test] + fn test_compute_vault_accounts_table_with_g1v1_partial() { + let mother = mother_account_tree_node(); + let child1 = mother.borrow().children[0].clone(); + let account_tree_nodes = vec![child1]; + + // Test with show_g1v1 = true (default behavior) + let table_with_g1v1 = + compute_vault_accounts_table_with_g1v1(&account_tree_nodes, true).unwrap(); + let expected_table_with_g1v1 = indoc! {r#" + ┌─────────────────────────────────────────────────────────────────────────────────────┠+ │ SS58 Address/G1v1 public key Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + │ ├ 5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH //0 Child 1 │ + │ │ ├ 5Fh5PLQNt1xuEXm71dfDtQdnwceSew4oHewWBLsWAkKspV7d //0 Grandchild 1 │ + └─────────────────────────────────────────────────────────────────────────────────────┘"# + }; + + assert_eq!(table_with_g1v1.to_string(), expected_table_with_g1v1); + + // Test with show_g1v1 = false + let table_without_g1v1 = + compute_vault_accounts_table_with_g1v1(&account_tree_nodes, false).unwrap(); + let expected_table_without_g1v1 = indoc! {r#" + ┌─────────────────────────────────────────────────────────────────────────────────────┠+ │ SS58 Address Crypto Path Name │ + ╞â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•â•¡ + │ ├ 5D34dL5prEUaGNQtPPZ3yN5Y6BnkfXunKXXz6fo7ZJbLwRRH //0 Child 1 │ + │ │ ├ 5Fh5PLQNt1xuEXm71dfDtQdnwceSew4oHewWBLsWAkKspV7d //0 Grandchild 1 │ + └─────────────────────────────────────────────────────────────────────────────────────┘"# + }; + + assert_eq!(table_without_g1v1.to_string(), expected_table_without_g1v1); + } } } diff --git a/src/data.rs b/src/data.rs index 1e877c0977c8b760d4f00d0c4a596d22eb92957f..0a41ea8db3d531027bcaa4bdbc2a8c1fd157352b 100644 --- a/src/data.rs +++ b/src/data.rs @@ -123,7 +123,7 @@ impl Data { match self.keypair.clone() { Some(keypair) => keypair, None => loop { - match fetch_or_get_keypair(self, self.cfg.address.clone()).await { + match fetch_or_get_keypair(self, self.cfg.address.clone(), None).await { Ok(pair) => return pair, Err(e) => { //Adapted code to still be able to go out of the loop when user hit "Esc" key or "ctrl+c" when prompted for a value @@ -207,7 +207,11 @@ impl Data { } // secret format and value if let Some(secret_format) = self.args.secret_format { - let keypair = get_keypair(secret_format, self.args.secret.as_deref())?; + let keypair = get_keypair( + secret_format, + self.args.secret.as_deref(), + self.args.crypto_scheme, + )?; self.cfg.address = Some(keypair.address()); self.keypair = Some(keypair); } diff --git a/src/entities/vault_account.rs b/src/entities/vault_account.rs index d04b02ad71ce2f1805c7b63a683a28d834d545ad..60d663d9cd13c408168ffe93eccb709662ed5b30 100644 --- a/src/entities/vault_account.rs +++ b/src/entities/vault_account.rs @@ -134,7 +134,10 @@ impl sea_orm::TryGetable for DbAccountId { .try_get_by(idx) .map_err(|e| TryGetError::Null(e.to_string()))?; Ok(DbAccountId(AccountId::from_str(&value).map_err(|e| { - TryGetError::DbErr(DbErr::Custom(e.to_string())) + TryGetError::DbErr(DbErr::Custom(format!( + "Cannot parse DbAccountId for string '{}' - error: {}", + &value, e + ))) })?)) } } @@ -757,6 +760,9 @@ where Ok(Some(base_parent_account)) } +/// Finds direct children of given account. +/// +/// Sorts according to the Path as it makes the most sense when viewing derivations of one account. async fn find_direct_children_accounts<C>( db: &C, current_account: &Model, @@ -766,7 +772,7 @@ where { Entity::find() .filter(Column::Parent.eq(current_account.address.clone())) - .order_by_asc(Column::Address) + .order_by_asc(Column::Path) .all(db) .await .map_err(GcliError::from) diff --git a/src/inputs.rs b/src/inputs.rs index 082313029575334b85d44ce9392121bb406ccd13..56ad96fdc6339a39d5effabd69f9f14d6c499a49 100644 --- a/src/inputs.rs +++ b/src/inputs.rs @@ -8,10 +8,6 @@ pub fn prompt_password() -> Result<String, GcliError> { prompt_password_query("Password") } -pub fn prompt_password_confirm() -> Result<String, GcliError> { - prompt_password_query_confirm("Password") -} - pub fn prompt_password_query(query: impl ToString) -> Result<String, GcliError> { inquire::Password::new(query.to_string().as_str()) .without_confirmation() @@ -39,12 +35,6 @@ pub fn prompt_seed() -> Result<String, GcliError> { .map_err(|e| GcliError::Input(e.to_string())) } -pub fn prompt_password_query_confirm(query: impl ToString) -> Result<String, GcliError> { - inquire::Password::new(query.to_string().as_str()) - .prompt() - .map_err(|e| GcliError::Input(e.to_string())) -} - /// Prompt for a (direct) vault name (cannot contain derivation path) /// /// Also preventing to use '<' and '>' as those are used in the display @@ -56,15 +46,16 @@ where C: ConnectionTrait, { loop { - let mut text_inquire = inquire::Text::new("Name:").with_validator({ - |input: &str| { - if input.contains('<') || input.contains('>') || input.contains('/') { - return Ok(Validation::Invalid( - "Name cannot contain characters '<', '>', '/'".into(), - )); + let mut text_inquire = inquire::Text::new("Name:").with_validator(|input: &str| { + match validate_vault_name(input) { + Ok(_) => Ok(Validation::Valid), + Err(error) => { + if let GcliError::Input(message) = error { + Ok(Validation::Invalid(ErrorMessage::from(message))) + } else { + Ok(Validation::Invalid("Unknown error".into())) + } } - - Ok(Validation::Valid) } }); @@ -76,11 +67,7 @@ where .prompt() .map_err(|e| GcliError::Input(e.to_string()))?; - let name = if name.trim().is_empty() { - None - } else { - Some(name.trim().to_string()) - }; + let name = trim_and_reduce_empty_as_none(name); let available = vault_account::check_name_available(db, initial_name, name.as_ref()).await?; @@ -96,31 +83,56 @@ where } } +pub fn trim_and_reduce_empty_as_none(name: String) -> Option<String> { + if name.trim().is_empty() { + None + } else { + Some(name.trim().to_string()) + } +} + +pub fn validate_vault_name(vault_name: &str) -> Result<(), GcliError> { + if vault_name.contains('<') || vault_name.contains('>') || vault_name.contains('/') { + return Err(GcliError::Input( + "Name cannot contain characters '<', '>', '/'".into(), + )); + } + + Ok(()) +} + /// Prompt for a derivation path pub fn prompt_vault_derivation_path() -> Result<String, GcliError> { inquire::Text::new("Derivation path:") - .with_validator(|input: &str| { - if !input.starts_with("/") { - Ok(Validation::Invalid( - "derivation path needs to start with one or more '/'".into(), - )) - } else { - match vault::parse_prefix_and_derivation_path_from_suri(input.to_string()) { - Ok(_) => Ok(Validation::Valid), - Err(error) => { - if let GcliError::Input(message) = error { - Ok(Validation::Invalid(ErrorMessage::from(message))) - } else { - Ok(Validation::Invalid("Unknown error".into())) - } + .with_validator( + |input: &str| match validate_derivation_path(input.to_string()) { + Ok(_) => Ok(Validation::Valid), + Err(error) => { + if let GcliError::Input(message) = error { + Ok(Validation::Invalid(ErrorMessage::from(message))) + } else { + Ok(Validation::Invalid("Unknown error".into())) } } - } - }) + }, + ) .prompt() .map_err(|e| GcliError::Input(e.to_string())) } +pub fn validate_derivation_path(derivation_path: String) -> Result<(), GcliError> { + if !derivation_path.starts_with("/") { + Err(GcliError::Input( + "derivation path needs to start with one or more '/'".into(), + )) + } else { + match vault::parse_prefix_and_derivation_path_from_suri(derivation_path.to_string()) { + Ok(_) => Ok(()), + Err(error) => Err(error), + } + } +} + pub fn confirm_action(query: impl ToString) -> Result<bool, GcliError> { inquire::Confirm::new(query.to_string().as_str()) .prompt() diff --git a/src/keys.rs b/src/keys.rs index 7b7babd095cd3b17230734bbd994a1de3bcf2680..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"; @@ -62,6 +64,15 @@ pub enum CryptoScheme { Sr25519, } +/// Setting a default to Ed25519 +/// +/// required when used in Args struct inside main.rs; even though we still have to give a clap "default_value" +impl Default for CryptoScheme { + fn default() -> Self { + CryptoScheme::Ed25519 + } +} + impl FromStr for CryptoScheme { type Err = std::io::Error; @@ -136,11 +147,43 @@ pub enum Signature { pub fn get_keypair( secret_format: SecretFormat, secret: Option<&str>, + crypto_scheme: CryptoScheme, ) -> Result<KeyPair, GcliError> { match (secret_format, secret) { - (SecretFormat::Predefined, Some(deriv)) => pair_from_predefined(deriv).map(|v| v.into()), - (secret_format, None) => Ok(prompt_secret(secret_format)), - (_, Some(secret)) => Ok(pair_from_secret(secret_format, secret)?.into()), + (SecretFormat::Predefined, Some(deriv)) => match crypto_scheme { + CryptoScheme::Ed25519 => { + pair_from_ed25519_str(&predefined_suri(deriv)).map(|v| v.into()) + } + _ => pair_from_predefined(deriv).map(|v| v.into()), + }, + (secret_format, None) => Ok(prompt_secret(secret_format, Some(crypto_scheme))), + (_, Some(secret)) => pair_from_secret_with_scheme(secret_format, secret, crypto_scheme), + } +} + +/// get keypair from given secret with specified crypto scheme +/// if secret is predefined, secret should contain the predefined value +pub fn pair_from_secret_with_scheme( + secret_format: SecretFormat, + secret: &str, + crypto_scheme: CryptoScheme, +) -> Result<KeyPair, GcliError> { + match (secret_format, crypto_scheme) { + (SecretFormat::G1v1, _) => Err(GcliError::Logic( + "G1v1 format incompatible with single secret".to_string(), + )), + (_, CryptoScheme::Ed25519) => match secret_format { + SecretFormat::Substrate => pair_from_ed25519_str(secret).map(|v| v.into()), + SecretFormat::Predefined => pair_from_ed25519_str(secret).map(|v| v.into()), + SecretFormat::Seed => { + let mut seed = [0; 32]; + hex::decode_to_slice(secret, &mut seed) + .map_err(|_| GcliError::Input("Invalid secret".to_string()))?; + Ok(ed25519::Pair::from_seed(&seed).into()) + } + SecretFormat::G1v1 => unreachable!(), // Already handled above + }, + (_, CryptoScheme::Sr25519) => pair_from_secret(secret_format, secret).map(|v| v.into()), } } @@ -194,14 +237,138 @@ 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_mnemonic(deriv: &str) -> String { +pub fn predefined_suri(deriv: &str) -> String { format!("{SUBSTRATE_MNEMONIC}//{deriv}") } /// get keypair from predefined secret pub fn pair_from_predefined(deriv: &str) -> Result<sr25519::Pair, GcliError> { - pair_from_sr25519_str(&predefined_mnemonic(deriv)) + pair_from_sr25519_str(&predefined_suri(deriv)) } /// get seed from G1v1 id/pwd (old "cesium") @@ -212,19 +379,33 @@ pub fn seed_from_cesium(id: &str, pwd: &str) -> [u8; 32] { seed } -/// ask user to input a secret -pub fn prompt_secret_substrate() -> sr25519::Pair { - // Only interested in the keypair which is the second element of the tuple - prompt_secret_substrate_and_compute_keypair().1 -} - -pub fn prompt_secret_substrate_and_compute_keypair() -> (String, sr25519::Pair) { +/// This method will prompt for the (secret) substrate uri, compute the keypair, and return a tuple containing the (secret) substrate uri and the keypair. +/// +/// # Arguments +/// +/// * `crypto_scheme` - The cryptographic scheme to use (either Sr25519 or Ed25519). +/// +/// # Returns +/// +/// A tuple containing: +/// +/// * `String` - The (secret) substrate URI provided by the user. +/// * `KeyPair` - The computed keypair based on the provided substrate URI and cryptographic scheme. +pub fn prompt_secret_substrate_and_compute_keypair( + crypto_scheme: CryptoScheme, +) -> (String, KeyPair) { loop { println!("Substrate URI can be a mnemonic or a mini-secret ('0x' prefixed seed) together with optional derivation path"); let substrate_suri = inputs::prompt_password_query("Substrate URI: ").unwrap(); - match pair_from_sr25519_str(&substrate_suri) { - Ok(pair) => return (substrate_suri, pair), - Err(_) => println!("Invalid secret"), + match crypto_scheme { + CryptoScheme::Sr25519 => match pair_from_sr25519_str(&substrate_suri) { + Ok(pair) => return (substrate_suri, pair.into()), + Err(_) => println!("Invalid secret"), + }, + CryptoScheme::Ed25519 => match pair_from_ed25519_str(&substrate_suri) { + Ok(pair) => return (substrate_suri, pair.into()), + Err(_) => println!("Invalid secret"), + }, } } } @@ -232,61 +413,73 @@ pub fn prompt_secret_substrate_and_compute_keypair() -> (String, sr25519::Pair) /// ask user pass (Cesium format) pub fn prompt_secret_cesium() -> ed25519::Pair { // Only interested in the keypair which is the second element of the tuple - prompt_secret_cesium_and_compute_keypair().1 + match prompt_secret_cesium_and_compute_keypair(CryptoScheme::Ed25519).1 { + KeyPair::Ed25519(pair) => pair, + _ => panic!("Expected Ed25519 keypair"), + } } -pub fn prompt_secret_cesium_and_compute_keypair() -> (String, ed25519::Pair) { +pub fn prompt_secret_cesium_and_compute_keypair(_crypto_scheme: CryptoScheme) -> (String, KeyPair) { let id = inputs::prompt_password_query("G1v1 id: ").unwrap(); let pwd = inputs::prompt_password_query("G1v1 password: ").unwrap(); let seed = seed_from_cesium(&id, &pwd); let secret_suri = format!("0x{}", hex::encode(seed)); + // G1v1 always uses Ed25519, ignore crypto_scheme match pair_from_ed25519_str(&secret_suri) { - Ok(pair) => (secret_suri, pair), + Ok(pair) => (secret_suri, pair.into()), Err(_) => panic!("Could not compute KeyPair from G1v1 id/pwd"), } } -/// ask user to input a seed -pub fn prompt_seed() -> sr25519::Pair { - // Only interested in the keypair which is the second element of the tuple - prompt_seed_and_compute_keypair().1 -} - -pub fn prompt_seed_and_compute_keypair() -> (String, sr25519::Pair) { +pub fn prompt_seed_and_compute_keypair(crypto_scheme: CryptoScheme) -> (String, KeyPair) { loop { let seed_str = inputs::prompt_seed().unwrap(); let secret_suri = format!("0x{}", seed_str); - match pair_from_sr25519_str(&secret_suri) { - Ok(pair) => return (secret_suri, pair), - Err(_) => println!("Invalid seed"), + match crypto_scheme { + CryptoScheme::Sr25519 => match pair_from_sr25519_str(&secret_suri) { + Ok(pair) => return (secret_suri, pair.into()), + Err(_) => println!("Invalid seed"), + }, + CryptoScheme::Ed25519 => match pair_from_ed25519_str(&secret_suri) { + Ok(pair) => return (secret_suri, pair.into()), + Err(_) => println!("Invalid seed"), + }, } } } -/// ask user pass (Cesium format) -pub fn prompt_predefined() -> sr25519::Pair { - // Only interested in the keypair which is the second element of the tuple - prompt_predefined_and_compute_keypair().1 -} - -pub fn prompt_predefined_and_compute_keypair() -> (String, sr25519::Pair) { +pub fn prompt_predefined_and_compute_keypair(crypto_scheme: CryptoScheme) -> (String, KeyPair) { let deriv = inputs::prompt_password_query("Enter derivation path: ").unwrap(); - ( - predefined_mnemonic(&deriv), - pair_from_predefined(&deriv).expect("invalid secret"), - ) + let suri = predefined_suri(&deriv); + + match crypto_scheme { + CryptoScheme::Sr25519 => match pair_from_sr25519_str(&suri) { + Ok(pair) => (suri, pair.into()), + Err(e) => panic!("Invalid secret: {}", e), + }, + CryptoScheme::Ed25519 => match pair_from_ed25519_str(&suri) { + Ok(pair) => (suri, pair.into()), + Err(e) => panic!("Invalid secret: {}", e), + }, + } } -/// ask user secret in relevant format -pub fn prompt_secret(secret_format: SecretFormat) -> KeyPair { +pub fn prompt_secret(secret_format: SecretFormat, crypto_scheme: Option<CryptoScheme>) -> KeyPair { + let default_scheme = match secret_format { + SecretFormat::G1v1 => CryptoScheme::Ed25519, // G1v1 always uses Ed25519 + _ => CryptoScheme::Ed25519, // All formats use Ed25519 by default + }; + + let scheme = crypto_scheme.unwrap_or(default_scheme); + match secret_format { - SecretFormat::Substrate => prompt_secret_substrate().into(), - SecretFormat::G1v1 => prompt_secret_cesium().into(), - SecretFormat::Seed => prompt_seed().into(), - SecretFormat::Predefined => prompt_predefined().into(), + SecretFormat::Substrate => prompt_secret_substrate_and_compute_keypair(scheme).1, + SecretFormat::G1v1 => prompt_secret_cesium_and_compute_keypair(CryptoScheme::Ed25519).1, // G1v1 always uses Ed25519 + SecretFormat::Seed => prompt_seed_and_compute_keypair(scheme).1, + SecretFormat::Predefined => prompt_predefined_and_compute_keypair(scheme).1, } } @@ -294,12 +487,18 @@ pub fn prompt_secret(secret_format: SecretFormat) -> KeyPair { pub async fn fetch_or_get_keypair( data: &Data, address: Option<AccountId>, + crypto_scheme: Option<CryptoScheme>, ) -> Result<KeyPair, GcliError> { if let Some(address) = address { // if address corresponds to predefined, (for example saved to config) - // keypair is already known (useful for dev mode) - if let Some(d) = catch_known(&address.to_string()) { - return Ok(pair_from_predefined(d).unwrap().into()); + // keypair is already known (useful for dev mode) - also overrides crypto_scheme if found + if let Some((deriv, crypto_scheme)) = catch_known(&address.to_string()) { + return match crypto_scheme { + CryptoScheme::Ed25519 => { + pair_from_ed25519_str(&predefined_suri(deriv)).map(|v| v.into()) + } + CryptoScheme::Sr25519 => pair_from_predefined(deriv).map(|v| v.into()), + }; }; // look for corresponding KeyPair in keystore @@ -309,17 +508,31 @@ pub async fn fetch_or_get_keypair( } // at the moment, there is no way to confg gcli to use an other kind of secret // without telling explicitly each time - Ok(prompt_secret(SecretFormat::Substrate)) + Ok(prompt_secret(SecretFormat::Substrate, crypto_scheme)) } // catch known addresses -fn catch_known(address: &str) -> Option<&str> { +fn catch_known(address: &str) -> Option<(&str, CryptoScheme)> { match address { - "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" => Some("Alice"), - "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" => Some("Bob"), - "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" => Some("Charlie"), - "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" => Some("Dave"), - "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" => Some("Eve"), + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" => { + Some(("Alice", CryptoScheme::Sr25519)) + } + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" => Some(("Bob", CryptoScheme::Sr25519)), + "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" => { + Some(("Charlie", CryptoScheme::Sr25519)) + } + "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" => Some(("Dave", CryptoScheme::Sr25519)), + "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" => Some(("Eve", CryptoScheme::Sr25519)), + + "5FA9nQDVg267DEd8m1ZypXLBnvN7SFxYwV7ndqSYGiN9TTpu" => { + Some(("Alice", CryptoScheme::Ed25519)) + } + "5GoNkf6WdbxCFnPdAnYYQyCjAKPJgLNxXwPjwTh6DGg6gN3E" => Some(("Bob", CryptoScheme::Ed25519)), + "5DbKjhNLpqX3zqZdNBc9BGb4fHU1cRBaDhJUskrvkwfraDi6" => { + Some(("Charlie", CryptoScheme::Ed25519)) + } + "5ECTwv6cZ5nJQPk6tWfaTrEk8YH2L7X1VT4EL5Tx2ikfFwb7" => Some(("Dave", CryptoScheme::Ed25519)), + "5Ck2miBfCe1JQ4cY3NDsXyBaD6EcsgiVmEFTWwqNSs25XDEq" => Some(("Eve", CryptoScheme::Ed25519)), _ => None, } } @@ -447,6 +660,7 @@ mod tests { mod substrate { use super::*; + use crate::keys::SUBSTRATE_MNEMONIC; /// Testing sr25519 mnemonic derivations /// @@ -685,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 @@ -719,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(); @@ -832,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(); @@ -876,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()), + } + } + } } diff --git a/src/main.rs b/src/main.rs index 116552d6c3228bf19d8f5c0451555925b856298d..26a87dac18045b019ffe7b6e0539ccae99790b5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,13 +46,16 @@ pub struct Args { /// Do not use indexer #[clap(long)] no_indexer: bool, + /// Secret key format (seed, substrate, g1v1) + #[clap(short = 'S', long)] + secret_format: Option<SecretFormat>, /// Secret key or BIP39 mnemonic (only used when secret format is compatible) /// (eventually followed by derivation path) #[clap(short, long)] secret: Option<String>, - /// Secret key format (seed, substrate, g1v1) - #[clap(short = 'S', long)] - secret_format: Option<SecretFormat>, + /// Crypto scheme to use (sr25519, ed25519) + #[clap(short = 'c', long, required = false, default_value = CryptoScheme::Ed25519)] + crypto_scheme: CryptoScheme, /// SS58 Address #[clap(short, conflicts_with = "name")] address: Option<AccountId>,