diff --git a/Cargo.lock b/Cargo.lock index b1dc7d0cae4c152c27a391b653e790b731dfb226..a20fbf8ca32221c2f9e3c78bf7c45d7afcbe0a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,6 +25,38 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee2a4ec343196209d6594e19543ae87a39f96d5534d7174822a3ad825dd6ed7e" +[[package]] +name = "aes" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7001367fde4c768a19d1029f0a8be5abd9308e1119846d5bd9ad26297b8faf5" +dependencies = [ + "aes-soft", + "aesni", + "block-cipher", +] + +[[package]] +name = "aes-soft" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4925647ee64e5056cf231608957ce7c81e12d6d6e316b9ce1404778cc1d35fa7" +dependencies = [ + "block-cipher", + "byteorder", + "opaque-debug 0.2.3", +] + +[[package]] +name = "aesni" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050d39b0b7688b3a3254394c3e30a9d66c41dcf9b05b0e2dbdc623f6505d264" +dependencies = [ + "block-cipher", + "opaque-debug 0.2.3", +] + [[package]] name = "ahash" version = "0.4.7" @@ -330,6 +362,15 @@ dependencies = [ "generic-array 0.14.4", ] +[[package]] +name = "block-cipher" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa136449e765dc7faa244561ccae839c394048667929af599b5d931ebe7b7f10" +dependencies = [ + "generic-array 0.14.4", +] + [[package]] name = "block-padding" version = "0.1.5" @@ -574,6 +615,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "cryptoxide" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46212f5d1792f89c3e866fb10636139464060110c568edd7f73ab5e9f736c26d" + [[package]] name = "darling" version = "0.10.2" @@ -886,11 +933,16 @@ version = "0.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5612539417a1204863d10dda1cb313fe7a34da8596d30fa93da8af125c928abb" dependencies = [ + "aes", + "arrayvec", "base64 0.12.3", "blake3", "bs58", "byteorder", + "cryptoxide", + "ed25519-bip32", "getrandom 0.1.15", + "once_cell", "ring", "serde", "thiserror", @@ -898,6 +950,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ed25519-bip32" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8827180a2b511141fbe49141e50b31a8d542465e0fb572f81f36feea2addfe92" +dependencies = [ + "cryptoxide", +] + [[package]] name = "either" version = "1.6.1" @@ -1182,9 +1243,11 @@ version = "0.1.0" dependencies = [ "anyhow", "duniter-gva-gql", + "dup-crypto", "graphql_client", "mockall", "reqwest", + "rpassword", "serde", "structopt", ] @@ -2245,6 +2308,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "rustc-demangle" version = "0.1.18" diff --git a/Cargo.toml b/Cargo.toml index 86f95bdb8cf303b3bb6bd6f460fcaf429cf7d970..f94dd187967814dd0862ae6250f4f98be5f2532e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,10 @@ description = "A command line client written in Rust that use Duniter GVA API." [dependencies] anyhow = "1.0.32" +dup-crypto = { version = "0.43.2", features = [ "bip32-ed25519", "dewif", "mnemonic"] } graphql_client = "0.9.0" reqwest = { version = "0.10.9", features = ["blocking", "json"] } +rpassword = "5.0.1" serde = { version = "1.0.105", features = ["derive"] } structopt = "0.3.18" diff --git a/README.md b/README.md index f1a7d4275b93a31c7c0a14cbe6dd25cdeee620a8..49333628e494b7baa96c78d62d6281a10678d104 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,89 @@ A simple command line client written in Rust that use Duniter GVA API. +## Use + +### Generate new wallet + +Use command `wallet gen`: + +```bash +gcli wallet gen +Mnemonic: sibling pelican sense stereo plastic helmet book expand tube whale census multiply +Password: +DEWIF: AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== +``` + +You can generate a mnemonic only with option `--mnemonic-only`: + +```bash +gcli wallet gen --mnemonic-only +sibling pelican sense stereo plastic helmet book expand tube whale census multiply +``` + +You can choose another language, change mnemonic length, etc. See `wallet gen --help` for details. + +#### Non-Interactive + +You can choose a password in a non-interactive way with option `--password-stdin`: + +```bash +echo "1234" | gcli wallet gen --password-stdin +Mnemonic: sibling pelican sense stereo plastic helmet book expand tube whale census multiply +DEWIF: AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== +``` + +### Import a wallet form mnemonic + +```bash +gcli wallet import +Mnemonic: sibling pelican sense stereo plastic helmet book expand tube whale census multiply +Password: +DEWIF: AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== +``` + +You can choose another language, and other parameters. See `wallet import --help` for details. + +#### Non-Interactive + +You can choose a mnemonic and password in a non-interactive way with environment variable `MNEMONIC` and option `--password-stdin`: + +```bash +echo "1234" | MNEMONIC="sibling pelican sense stereo plastic helmet book expand tube whale census multiply" gcli wallet import --password-stdin +DEWIF: AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== +``` + +### Get a public key (=address) + +To obtain the public key of the sub-account whose derivation number is `3`: + +```bash +gcli wallet get-pubkey --dewif AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== 3 +Password: +2S7wAUzaXfG3Z1SWNULqBotnUb9QbucmeuVkqVL4X8FB +``` + +To obtain the master external* public key of the sub-account whose derivation number is `3`: + +```bash +gcli wallet get-pubkey --external --dewif AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== 3 +Password: +9KV3gJFcsEH98FQhF3vPW4YG15VGRybHZuzAeD3oVicx +``` + +* The master external public key allows you to generate all payment receipt addresses by public key derivation. This is the key to be configured on your server to generate the payment addresses that will be provided to your customers. + +You can also obtain an external or internal address in particular by its derivation index, see `wallet get-pubkey --help` for details. + +#### Non-Interactive + +You can enter your password in a non-interactive way with option `--password-stdin`: + +```bash +echo "1234" | gcli wallet get-pubkey --dewif AAAABAAAAAEMGzFNV+j80uJkBuAfazfFWZfz0ARlA5T/Ea3ZQTo7zjTiALR/jkf/pcBLFlsqtERCLozLcRP8Q74qj1NSFZ79Fw== 3 +2S7wAUzaXfG3Z1SWNULqBotnUb9QbucmeuVkqVL4X8FB +``` + ## Contribute Contributions are welcome :) diff --git a/src/commands.rs b/src/commands.rs index f59775c0e73f5a5af79bdbb6b26590df2f137b6a..038341a0d2911076c3d85706c7f7f64e34562f2f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -16,6 +16,7 @@ pub mod balance; pub mod current_ud; pub mod members_count; +pub mod wallet; use crate::*; @@ -31,4 +32,9 @@ pub(crate) enum Command { CurrentUd, /// Get current number of WoT members MembersCount, + /// Create or update a wallet + Wallet { + #[structopt(subcommand)] + command: wallet::WalletCommand, + }, } diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs new file mode 100644 index 0000000000000000000000000000000000000000..a1ae9bb4ef7d48bdf8415fc325b4bec638c0f40c --- /dev/null +++ b/src/commands/wallet.rs @@ -0,0 +1,215 @@ +// Copyright (C) 2020 Éloïs SANCHEZ. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <https://www.gnu.org/licenses/>. + +use crate::*; +use dup_crypto::keys::{ed25519::bip32::KeyPair, KeyPair as _}; +use dup_crypto::mnemonic::{mnemonic_to_seed, Language, Mnemonic, MnemonicType}; +use dup_crypto::{ + bases::b58::ToBase58, + dewif::{ + read_dewif_file_content, write_dewif_v4_content, Currency, ExpectedCurrency, G1_CURRENCY, + }, + keys::ed25519::bip32::DerivationIndex, +}; + +#[derive(StructOpt)] +pub enum WalletCommand { + /// Generate a wallet + Gen { + #[structopt(short, long, default_value = "en")] + lang: Language, + #[structopt(long, default_value = "12")] + log_n: u8, + #[structopt(short, long)] + mnemonic_only: bool, + #[structopt(long)] + password_stdin: bool, + /// Mnemonic word count + #[structopt(short, long, parse(try_from_str = parse_mnemonic_len), default_value = "12")] + word_count: MnemonicType, + }, + /// Get a public key + GetPubkey { + account_index: u32, + address_index: Option<u32>, + #[structopt(long)] + dewif: Option<String>, + #[structopt(long, parse(from_os_str))] + dewif_file: Option<PathBuf>, + #[structopt(short, long)] + external: bool, + #[structopt(long)] + password_stdin: bool, + }, + /// Import a wallet from mnemonic + Import { + #[structopt(short, long, default_value = "en")] + lang: Language, + #[structopt(long, default_value = "12")] + log_n: u8, + #[structopt(long)] + password_stdin: bool, + }, +} + +pub(crate) fn wallet<W: Write>(out: &mut W, command: WalletCommand) -> anyhow::Result<()> { + match command { + WalletCommand::Gen { + lang, + mnemonic_only, + log_n, + password_stdin, + word_count, + } => { + let mnemonic = Mnemonic::new(word_count, lang) + .map_err(|_| anyhow::Error::msg("unspecified rand error"))?; + + if mnemonic_only { + writeln!(out, "{}", mnemonic.phrase())?; + } else { + writeln!(out, "Mnemonic: {}", mnemonic.phrase())?; + gen_dewif(out, log_n, mnemonic, password_stdin)?; + } + + Ok(()) + } + WalletCommand::Import { + lang, + log_n, + password_stdin, + } => { + let mnemonic_phrase = if let Some(mnemonic_env) = var_os("MNEMONIC") { + mnemonic_env + .into_string() + .map_err(|_| anyhow::Error::msg("invalid utf8 string"))? + } else { + rpassword::prompt_password_stdout("Mnemonic: ")? + }; + let mnemonic = Mnemonic::from_phrase(mnemonic_phrase, lang)?; + gen_dewif(out, log_n, mnemonic, password_stdin)?; + Ok(()) + } + WalletCommand::GetPubkey { + account_index, + address_index, + dewif, + dewif_file, + external, + password_stdin, + } => { + let dewif_content = get_dewif_content(dewif, dewif_file)?; + let keypair = get_keypair_from_dewif(dewif_content, password_stdin)?; + + let account_keypair = keypair.derive(DerivationIndex::hard(account_index)?); + + if external { + let external_keypair = account_keypair.derive(DerivationIndex::hard(0)?); + if let Some(address_index) = address_index { + let address_keypair = + external_keypair.derive(DerivationIndex::soft(address_index)?); + writeln!(out, "{}", address_keypair.public_key().to_base58())?; + } else { + writeln!(out, "{}", external_keypair.public_key().to_base58())?; + } + } else if let Some(address_index) = address_index { + let internal_keypair = account_keypair.derive(DerivationIndex::hard(1)?); + let address_keypair = + internal_keypair.derive(DerivationIndex::soft(address_index)?); + writeln!(out, "{}", address_keypair.public_key().to_base58())?; + } else { + writeln!(out, "{}", account_keypair.public_key().to_base58())?; + } + + Ok(()) + } + } +} + +fn parse_mnemonic_len(s: &str) -> anyhow::Result<MnemonicType> { + Ok(MnemonicType::for_word_count(s.parse()?)?) +} + +fn gen_dewif<W: Write>( + out: &mut W, + log_n: u8, + mnemonic: Mnemonic, + password_stdin: bool, +) -> anyhow::Result<()> { + let seed = mnemonic_to_seed(&mnemonic); + let keypair = KeyPair::from_seed(seed.clone()); + + let password = if password_stdin { + rpassword::read_password()? + } else { + rpassword::prompt_password_stdout("Password: ")? + }; + //println!("TMP DEBUG: password={}", password); + let dewif = write_dewif_v4_content( + Currency::from(G1_CURRENCY), + log_n, + &password, + &keypair.public_key(), + seed, + ); + + writeln!(out, "DEWIF: {}", dewif)?; + + Ok(()) +} + +fn get_dewif_content( + dewif_opt: Option<String>, + dewif_file_opt: Option<PathBuf>, +) -> anyhow::Result<String> { + if let Some(dewif_env) = var_os("DEWIF") { + dewif_env + .into_string() + .map_err(|_| anyhow::Error::msg("invalid utf8 string")) + } else if let Some(dewif_file_env) = var_os("DEWIF_FILE") { + let mut buf_reader = BufReader::new(File::open(dewif_file_env)?); + let mut contents = String::new(); + buf_reader.read_to_string(&mut contents)?; + Ok(contents) + } else if let Some(dewif) = dewif_opt { + Ok(dewif) + } else if let Some(dewif_file) = dewif_file_opt { + let mut buf_reader = BufReader::new(File::open(dewif_file)?); + let mut contents = String::new(); + buf_reader.read_to_string(&mut contents)?; + Ok(contents) + } else { + Err(anyhow::Error::msg("no DEWIF provided")) + } +} + +fn get_keypair_from_dewif(dewif_content: String, password_stdin: bool) -> anyhow::Result<KeyPair> { + let password = if password_stdin { + rpassword::read_password()? + } else { + rpassword::prompt_password_stdout("Password: ")? + }; + + let mut keypairs = read_dewif_file_content( + ExpectedCurrency::Specific(Currency::from(G1_CURRENCY)), + &dewif_content, + &password, + )?; + + if let Some(dup_crypto::keys::KeyPairEnum::Bip32Ed25519(keypair)) = keypairs.next() { + Ok(keypair) + } else { + Err(anyhow::Error::msg("DEWIF corrupted")) + } +} diff --git a/src/main.rs b/src/main.rs index 9958830eaf390436c4a8fae03e5d288c1d8fa35c..e5cdd9e5bbaf6bd92aa46e7536362ce466967271 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,11 +32,18 @@ use crate::client::Client; #[cfg(test)] use crate::client::MockClient as Client; use crate::commands::Command; -use commands::{balance::balance, current_ud::current_ud, members_count::members_count}; +use commands::{ + balance::balance, current_ud::current_ud, members_count::members_count, wallet::wallet, +}; use graphql_client::GraphQLQuery; use graphql_client::Response; -use std::io::Write; -use std::time::Instant; +use std::{ + env::var_os, + fs::File, + io::{BufReader, Read, Write}, + path::PathBuf, + time::Instant, +}; use structopt::StructOpt; const DEFAULT_GVA_SERVER: &str = "https://g1.librelois.fr/gva"; @@ -76,6 +83,7 @@ fn main() -> anyhow::Result<()> { } => balance(&client, &mut out, &pubkey_or_script, ud_unit)?, Command::CurrentUd => current_ud(&client, &mut out)?, Command::MembersCount => members_count(&client, &mut out)?, + Command::Wallet { command } => wallet(&mut out, command)?, } Ok(()) }