diff --git a/Cargo.lock b/Cargo.lock index ae6df0a9238d6e11769df01611dd97784fba0718..1e61c3201c938d15c1979b510a5aa429ebee05e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,49 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "age" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edeef7d7b199195a2d7d7a8155d2d04aee736e60c5c7bdd7097d115369a8817d" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac 0.12.1", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom", + "pin-project", + "rand", + "rust-embed", + "scrypt", + "sha2 0.10.8", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5f11899bc2bbddd135edbc30c36b1924fa59d0746bb45beb5933fafe3fe509b" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom", + "rand", + "secrecy", + "sha2 0.10.8", +] + [[package]] name = "ahash" version = "0.7.7" @@ -163,6 +206,12 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "arc-swap" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" + [[package]] name = "ark-bls12-377" version = "0.4.0" @@ -710,6 +759,12 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "beef" version = "0.5.2" @@ -933,6 +988,19 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.33" @@ -953,6 +1021,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1051,7 +1120,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e37668cb35145dcfaa1931a5f37fde375eeae8068b4c0d2f289da28a270b2d2c" dependencies = [ - "directories", + "directories 4.0.1", "serde", "thiserror", "toml 0.5.11", @@ -1087,6 +1156,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1359,6 +1434,19 @@ dependencies = [ "syn 2.0.48", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.3", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "der" version = "0.7.8" @@ -1439,7 +1527,16 @@ version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210" dependencies = [ - "dirs-sys", + "dirs-sys 0.3.7", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", ] [[package]] @@ -1453,6 +1550,29 @@ dependencies = [ "winapi", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487585f4d0c6655fe74905e2504d8ad6908e4db67f744eb140876906c2f3175d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "dleq_vrf" version = "0.0.2" @@ -1709,6 +1829,15 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1676f435fc1dadde4d03e43f5d62b259e1ce5f40bd4ffb21db2b42ebe59c1382" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -1721,6 +1850,50 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fluent" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f69378194459db76abd2ce3952b790db103ceb003008d3d50d97c41ff847a7" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e242c601dec9711505f6d5bbff5bedd4b61b2469f2e8bb8e57ee7c9747a87ffd" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ad0989667548f06ccd0e306ed56b61bd4d35458d54df5ec7587c0e8ed5e94" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0abed97648395c902868fee9026de96483933faa54ea3b40d652f7dfe61ca78" +dependencies = [ + "thiserror", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1900,12 +2073,15 @@ dependencies = [ [[package]] name = "gcli" -version = "0.2.6" +version = "0.2.7" dependencies = [ + "age", "anyhow", + "bip39", "bs58", "clap", "confy", + "directories 5.0.1", "env_logger", "futures", "graphql_client", @@ -2122,6 +2298,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.8.1" @@ -2255,6 +2440,75 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "i18n-config" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ce3c48cbc21fd5b22b9331f32b5b51f6ad85d969b99e793427332e76e7640" +dependencies = [ + "log", + "serde", + "serde_derive", + "thiserror", + "toml 0.8.10", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94205d95764f5bb9db9ea98fa77f89653365ca748e27161f5bbea2ffd50e459c" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "lazy_static", + "log", + "parking_lot", + "rust-embed", + "thiserror", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc1f8715195dffc4caddcf1cf3128da15fe5d8a137606ea8856c9300047d5a2" +dependencies = [ + "dashmap", + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.48", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81093c4701672f59416582fe3145676126fd23ba5db910acad0793c1108aaa58" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "iana-time-zone" version = "0.1.60" @@ -2393,6 +2647,25 @@ dependencies = [ "num-traits", ] +[[package]] +name = "intl-memoizer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c310433e4a310918d6ed9243542a6b83ec1183df95dff8f23f87bb88a264a66f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "io-lifetimes" version = "1.0.11" @@ -2404,6 +2677,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipnet" version = "2.9.0" @@ -2981,6 +3260,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parity-scale-codec" version = "3.6.9" @@ -3530,6 +3815,40 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rust-embed" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82c0bbc10308ed323529fd3c1dce8badda635aa319a5ff0e6466f33b8101e3f" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6227c01b1783cdfee1bcf844eb44594cd16ec71c35305bf1c9fb5aade2735e16" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.48", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb0a25bfbb2d4b4402179c2cf030387d9990857ce08a32592c6238db9fa8665" +dependencies = [ + "sha2 0.10.8", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -3980,6 +4299,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.0.3", +] + +[[package]] +name = "self_cell" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bf37232d3bb9a2c4e641ca2a11d83b5062066f88df7fed36c28772046d65ba" + [[package]] name = "semver" version = "1.0.21" @@ -5044,6 +5378,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tinystr" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83c02bf3c538ab32ba913408224323915f4ef9a6d61c0e85d493f355921c0ece" +dependencies = [ + "displaydoc", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -5363,6 +5706,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "type-map" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d3364c5e96cb2ad1603037ab253ddd34d7fb72a58bdddf4b7350760fc69a46" +dependencies = [ + "rustc-hash", +] + [[package]] name = "typenum" version = "1.17.0" @@ -5381,6 +5733,25 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unic-langid" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "238722e6d794ed130f91f4ea33e01fcff4f188d92337a21297892521c72df516" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd55a2063fdea4ef1f8633243a7b0524cbeef1905ae04c31a1c9b9775c55bc6" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicode-bidi" version = "0.3.15" diff --git a/Cargo.toml b/Cargo.toml index 16e9344cabc821a48957d3bbe691a70d90445b66..bd3166d885f09950c7b92f36a5b8195a8a67a6af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ rust-version = "1.75.0" license = "AGPL-3.0-only" name = "gcli" repository = "https://git.duniter.org/clients/rust/gcli-v2s" -version = "0.2.6" +version = "0.2.7" [dependencies] # subxt is main dependency @@ -18,9 +18,11 @@ subxt = { git = 'https://github.com/duniter/subxt', branch = 'subxt-v0.34.0-duni "native", "jsonrpsee", ] } + # substrate primitives dependencies sp-core = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.6.0" } sp-runtime = { git = "https://github.com/duniter/duniter-polkadot-sdk.git", branch = "duniter-substrate-v1.6.0" } + # crates.io dependencies anyhow = "^1.0" clap = { version = "^4.5.0", features = ["derive"] } @@ -36,10 +38,16 @@ serde = { version = "^1.0", features = ["derive"] } serde_json = "^1.0.113" tokio = { version = "^1.36.0", features = ["macros"] } confy = "^0.5.1" -scrypt = { version = "^0.11", default-features = false } # for old-style key generation -nacl = { version = "^0.5.3" } # for old-style key generation bs58 = "^0.5.0" inquire = "^0.6.2" +directories = "^5.0.1" + +# crypto +scrypt = { version = "^0.11", default-features = false } # for old-style key generation +nacl = { version = "^0.5.3" } # for old-style key generation +# this is beta crate for password-encrypted files +age = { default-features = false, version = "^0.10.0", features = ["armor"] } +bip39 = { version = "^2.0.0", features = ["rand"] } # mnemonic # allows to build gcli for different runtimes and with different predefined networks [features] diff --git a/README.md b/README.md index 8b082058984c748a53d2f7ba913d003697475f1a..defa7e778a2440240ec844d7daed0d75257767bd 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ List certifications and session keys that will expire within one month: cargo run -- --url wss://gdev.p2p.legal:443/ws smith expire --blocks 432000 -For more examples see [in the example file](./doc/example.md). +For more examples see [in the doc](./doc/). `cargo run --` is replaced by `gcli` as if the binary was added to your path. #### Log level diff --git a/doc/config.md b/doc/config.md index d4bf092ed6b9f95cfa8be944b4e1eb085093e3f8..cf012430f495a04af9dea9a034847df19b16492e 100644 --- a/doc/config.md +++ b/doc/config.md @@ -1,27 +1,72 @@ # Ğcli config Some Ğcli commands require to have an address configured (for example to get account balance), some require to have a secret configured (to sign extrinsics). -Ğcli allows to save what you want in a config file and to overwrite parts in command line arguments. Example: +Ğcli allows to save the address you want in a config file and to overwrite parts in command line arguments. Example: ```sh -# save Alice secret to config file -cargo run -- -S predefined -s Alice config save +# save Alice address to config file +gcli -S predefined -s Alice config save # show config -cargo run -- config show +gcli config show # [stdout] # Ğcli config # duniter endpoint ws://localhost:9944 # indexer endpoint http://localhost:4350/graphql -# address 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY (secret defined) +# address 5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY # use different address in command line -cargo run -- --address 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n config show +gcli --address 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n config show # [stdout] # Ğcli config # duniter endpoint ws://localhost:9944 # indexer endpoint http://localhost:4350/graphql -# address 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n (no secret) +# address 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n ``` -You can see that if a secret is defined, the associated address is used, but if an other address is given, the secret is silenced. \ No newline at end of file +This also applies to rpc endpoint config (`--url`) or indexer config (`--indexer`). + +## Using password encrypted vault + +For convenience, default accounts are hardcoded in Ğcli without needing a password: + +```sh +# when Alice address is stored in config file +gcli account transfer 1 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n +# no need for password to sign transaction +``` + +but in general usage, you want to store your secret in the local vault. This goes like this: + +```sh +# list available keys in the vault +gcli vault list +# [stdout] +# available keys: +# 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV + +# add a new secret to the vault +gcli vault import +# [stdout] +# Mnemonic: <enter mnemonic> +# Password: <enter password> +``` + +After saving your secret to the vault, you will be able to unlock it with the password: + +```sh +gcli account transfer 123 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n +# [stdout] +# Enter password to unlock account 5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV +# Password: <enter password> +# transaction submitted to the network, waiting 6 seconds... +# transfered 1.23 ĞD (5DfhGyQdFobKM8NsWvEeAKk5EQQgYe9AydgJ7rMB6E1EqRzV → 5Fxune7f71ZbpP2FoY3mhYcmM596Erhv1gRue4nsPwkxMR4n) +``` + +You can display the secret files location: + +```sh +gcli vault where +# [stdout] +# /home/hugo/.local/share/gcli +``` \ No newline at end of file diff --git a/doc/example.md b/doc/example.md index 7d3ea4515e4e8d76037cbca39f9763e5bbab7a54..a4a04fcf8a38e2c7a7e4de56a5fb03dd1c944ca6 100644 --- a/doc/example.md +++ b/doc/example.md @@ -1,10 +1,8 @@ # Examples of gcli commands for copy-paste -Useful when developing: replace `gcli` by `cargo run --` to build in debug mode and launch gcli. - ## Configuration -It can be handful to use Gcli with a configuration file to avoid passing arguments on every command. +It can be handful to use Ǧcli with a configuration file to avoid passing arguments on every command. ```sh # show config commands @@ -13,11 +11,11 @@ gcli config gcli config where # save config to use gdev network for next commands gcli --network gdev config save -# save config to use Alice predefined secret +# save config to use Alice predefined account gcli -S predefined -s Alice config save # the arguments above can be combined # command below sets local network and predefined secret -gcli --network local -S predefined -s test1 config save +gcli --network local -S predefined -s Alice config save ``` In the following, we assume this last command was run. More about the config in [config.md](./config.md). @@ -33,8 +31,8 @@ gcli blockchain current-block gcli account balance # get identity information without indexer gcli --no-indexer identity get -a 5Hn2LeMZXPFitMwrmrGucwtAPSLEiP4o5zTF7kHzMBtEkJUr -# get information about test1 identity (needs indexer) -gcli identity get --username test1 +# get information about Alice identity (needs indexer) +gcli identity get --username Alice # claim universal dividends gcli ud claim # transfer 5000 units diff --git a/src/commands.rs b/src/commands.rs index 277764b2f115d52555166b96be2221fcd64a23fe..f5bce93ef8cabaa3addbe8c9c4d64504feeed786 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -6,6 +6,7 @@ pub mod collective; pub mod distance; pub mod expire; pub mod identity; +pub mod vault; pub mod net_test; pub mod oneshot; pub mod publish; diff --git a/src/commands/vault.rs b/src/commands/vault.rs new file mode 100644 index 0000000000000000000000000000000000000000..14c376688f4f227e17eb7a89c981a0cedb4140eb --- /dev/null +++ b/src/commands/vault.rs @@ -0,0 +1,118 @@ +use crate::*; +use age::secrecy::Secret; +use std::io::{Read, Write}; + +/// define universal dividends subcommands +#[derive(Clone, Default, Debug, clap::Parser)] +pub enum Subcommand { + #[default] + /// List available keys + List, + /// Show where vault stores secret + Where, + /// Generate a mnemonic + Generate, + /// Import mnemonic with interactive prompt + Import, +} + +// encrypt input with passphrase +fn encrypt(input: &[u8], passphrase: String) -> Result<Vec<u8>, age::EncryptError> { + let encryptor = age::Encryptor::with_user_passphrase(Secret::new(passphrase)); + let mut encrypted = vec![]; + let mut writer = encryptor.wrap_output(age::armor::ArmoredWriter::wrap_output( + &mut encrypted, + age::armor::Format::AsciiArmor, + )?)?; + writer.write_all(input)?; + writer.finish().and_then(|armor| armor.finish())?; + Ok(encrypted) +} + +// decrypt cypher with passphrase +fn decrypt(input: &[u8], passphrase: String) -> Result<Vec<u8>, age::DecryptError> { + let age::Decryptor::Passphrase(decryptor) = + age::Decryptor::new(age::armor::ArmoredReader::new(input))? + else { + unimplemented!() + }; + let mut decrypted = vec![]; + let mut reader = decryptor.decrypt(&Secret::new(passphrase.to_owned()), None)?; + reader.read_to_end(&mut decrypted)?; + Ok(decrypted) +} + +/// handle ud commands +pub fn handle_command(data: Data, command: Subcommand) -> Result<(), GcliError> { + // match subcommand + match command { + Subcommand::List => { + if let Ok(entries) = std::fs::read_dir(data.project_dir.data_dir()) { + println!("available keys:"); + entries.for_each(|e| println!("{}", e.unwrap().file_name().to_str().unwrap())); + } else { + println!("could not read project dir"); + } + } + Subcommand::Where => { + println!("{}", data.project_dir.data_dir().to_str().unwrap()); + } + Subcommand::Generate => { + // TODO allow custom word count + let mnemonic = bip39::Mnemonic::generate(12).unwrap(); + println!("{mnemonic}"); + } + Subcommand::Import => { + let mnemonic = rpassword::prompt_password("Mnemonic: ")?; + println!("Enter password to protect the key"); + let password = rpassword::prompt_password("Password: ")?; + let address = store_mnemonic(&data, &mnemonic, password)?; + println!("Stored secret for {address}"); + } + }; + + Ok(()) +} + +/// store mnemonic protected with password +pub fn store_mnemonic( + data: &Data, + mnemonic: &str, + password: String, +) -> Result<AccountId, GcliError> { + // check validity by deriving keypair + let keypair = pair_from_str(&mnemonic)?; + let address = keypair.public(); + // write encrypted mnemonic in file identified by pubkey + let path = data.project_dir.data_dir().join(address.to_string()); + let mut file = std::fs::File::create(path)?; + file.write_all(&encrypt(mnemonic.as_bytes(), password).map_err(|e| anyhow!(e))?[..])?; + Ok(keypair.public().into()) +} + +/// try get secret in keystore +pub fn try_fetch_secret(data: &Data, address: AccountId) -> Result<Option<String>, GcliError> { + let path = data.project_dir.data_dir().join(address.to_string()); + if path.exists() { + println!("Enter password to unlock account {address}"); + let password = rpassword::prompt_password("Password: ")?; + let mut file = std::fs::OpenOptions::new().read(true).open(path)?; + let mut cypher = vec![]; + file.read_to_end(&mut cypher)?; + let secret = decrypt(&cypher, password).map_err(|e| GcliError::Input(e.to_string()))?; + let secretstr = String::from_utf8(secret).map_err(|e| anyhow!(e))?; + Ok(Some(secretstr)) + } else { + Ok(None) + } +} + +// test that armored encryption/decryption work as intended +#[test] +fn test_encrypt_decrypt() { + let plaintext = b"Hello world!"; + let passphrase = "this is not a good passphrase".to_string(); + let encrypted = encrypt(plaintext, passphrase.clone()).unwrap(); + let decrypted = decrypt(&encrypted, passphrase).unwrap(); + assert_eq!(decrypted, plaintext); +} diff --git a/src/conf.rs b/src/conf.rs index 0b8f68389e66095fb12292a91864c1bd148495ab..f07ffff1caaa1b1282cec11d52369fae14f6ea70 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -6,14 +6,13 @@ const APP_NAME: &str = "gcli"; /// defines structure of config file #[derive(Serialize, Deserialize, Debug)] pub struct Config { - // duniter endpoint + /// duniter endpoint pub duniter_endpoint: String, - // indexer endpoint + /// indexer endpoint pub indexer_endpoint: String, - // user address + /// user address + /// to perform actions, user must provide secret pub address: Option<AccountId>, - // user secret (substrate format) - pub secret: Option<String>, } impl std::default::Default for Config { @@ -22,18 +21,12 @@ impl std::default::Default for Config { duniter_endpoint: String::from(data::LOCAL_DUNITER_ENDPOINT), indexer_endpoint: String::from(data::LOCAL_INDEXER_ENDPOINT), address: None, - secret: None, } } } impl std::fmt::Display for Config { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let secret = if self.secret.is_some() { - "(secret defined)" - } else { - "(no secret)" - }; let address = if let Some(address) = &self.address { format!("{}", address) } else { @@ -42,7 +35,7 @@ impl std::fmt::Display for Config { writeln!(f, "Ğcli config")?; writeln!(f, "duniter endpoint {}", self.duniter_endpoint)?; writeln!(f, "indexer endpoint {}", self.indexer_endpoint)?; - write!(f, "address {address} {secret}") + write!(f, "address {address}") } } diff --git a/src/data.rs b/src/data.rs index 87a785400747bf69181695caf7fe637d99177682..ce0e06c5b1fda5c1fa05f0039d4e73aa0fb25ea4 100644 --- a/src/data.rs +++ b/src/data.rs @@ -25,7 +25,6 @@ pub const GDEV_INDEXER_ENDPOINTS: [&str; 2] = [ /// Data of current command /// can also include fetched information -#[derive(Default)] pub struct Data { // command line arguments pub args: Args, @@ -47,6 +46,8 @@ pub struct Data { pub genesis_hash: Hash, // indexer genesis hash pub indexer_genesis_hash: Hash, + // gcli base path + pub project_dir: directories::ProjectDirs, } /// system properties defined in client specs @@ -57,10 +58,32 @@ struct SystemProperties { token_symbol: String, } +impl Default for Data { + fn default() -> Self { + let project_dir = directories::ProjectDirs::from("org", "duniter", "gcli").unwrap(); + if !project_dir.data_dir().exists() { + std::fs::create_dir(project_dir.data_dir()).expect("could not create data dir"); + }; + Self { + project_dir, + args: Default::default(), + cfg: Default::default(), + client: Default::default(), + indexer: Default::default(), + keypair: Default::default(), + idty_index: Default::default(), + token_decimals: Default::default(), + token_symbol: Default::default(), + genesis_hash: Default::default(), + indexer_genesis_hash: Default::default(), + } + } +} + // implement helper functions for Data impl Data { /// --- constructor --- - pub fn new(args: Args) -> Self { + pub fn new(args: Args) -> Result<Self, GcliError> { Self { args, cfg: conf::load_conf(), @@ -69,7 +92,6 @@ impl Data { ..Default::default() } .overwrite_from_args() - .build_from_config() } // --- getters --- // the "unwrap" should not fail if data is well prepared @@ -85,7 +107,12 @@ impl Data { pub fn keypair(&self) -> KeyPair { match self.keypair.clone() { Some(keypair) => keypair, - None => prompt_secret(self.args.secret_format), + None => loop { + match fetch_or_get_keypair(self, self.cfg.address.clone()) { + Ok(pair) => return pair, + Err(e) => println!("{e:?} → retry"), + } + }, } } pub fn idty_index(&self) -> IdtyId { @@ -102,7 +129,7 @@ impl Data { } // --- mutators --- /// use arguments to overwrite config - pub fn overwrite_from_args(mut self) -> Self { + pub fn overwrite_from_args(mut self) -> Result<Self, GcliError> { // network if let Some(network) = self.args.network.clone() { // a network was provided as arugment @@ -145,49 +172,18 @@ impl Data { self.cfg.indexer_endpoint = indexer_endpoint } // secret format and value - if self.args.secret_format == SecretFormat::Predefined { - // predefined secret format overwrites secret with mnemonic - match self.args.secret.clone() { - None => {} - Some(derivation) => { - self.cfg.secret = Some(predefined_mnemonic(&derivation)); - } - }; - } else if self.args.secret_format == SecretFormat::Cesium { - // cesium secret format also overwrites, to force valid prompt - self.cfg.secret = None - } else if let Some(secret) = self.args.secret.clone() { - // other secret type - self.cfg.secret = Some(secret); + if let Some(secret_format) = self.args.secret_format { + let keypair = get_keypair(secret_format, self.args.secret.as_deref())?; + self.cfg.address = Some(keypair.address()); + self.keypair = Some(keypair); } // address if let Some(address) = self.args.address.clone() { self.cfg.address = Some(AccountId::from_str(&address).expect("invalid address")); // if giving address, cancel secret - self.cfg.secret = None - } - self - } - /// build from config - pub fn build_from_config(mut self) -> Self { - let secret_format = self.args.secret_format; - // prevent incoherent state - if secret_format == SecretFormat::Cesium && self.cfg.secret.is_some() { - panic!("incompatible input: secret arg with cesium format"); - } - // if secret format is cesium, force a prompt now and record keypair - if secret_format == SecretFormat::Cesium { - let keypair = prompt_secret(SecretFormat::Cesium); - self.cfg.address = Some(keypair.address()); - self.keypair = Some(keypair); + self.keypair = None } - // if a secret is defined (format should not be cesium), build keypair and silently overwrite address - if let Some(secret) = self.cfg.secret.clone() { - let keypair = pair_from_secret(secret_format, &secret).expect("invalid secret"); - self.cfg.address = Some(keypair.public().into()); - self.keypair = Some(keypair.into()); - } - self + Ok(self) } /// build a client from url pub async fn build_client(mut self) -> Result<Self, GcliError> { diff --git a/src/keys.rs b/src/keys.rs index ad49f8f9a6c390811752b0166f4fe04b4dab858d..ae695c901cd7a205ab7be45c6fa4e22846eb3f4e 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -93,12 +93,9 @@ pub fn get_keypair( secret: Option<&str>, ) -> Result<KeyPair, GcliError> { match (secret_format, secret) { - (SecretFormat::Cesium, None) => Ok(prompt_secret(SecretFormat::Cesium)), (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()), - _ => Err(GcliError::Logic( - "can not get keypair from available options".to_string(), - )), } } @@ -125,6 +122,8 @@ pub fn pair_from_str(secret: &str) -> Result<Sr25519Pair, GcliError> { } /// get keypair from given seed +// note: Sr25519Pair::from_string does exactly that when seed is 0x prefixed +// (see from_string_with_seed method in crypto core) pub fn pair_from_seed(secret: &str) -> Result<Sr25519Pair, GcliError> { let mut seed = [0; 32]; hex::decode_to_slice(secret, &mut seed) @@ -195,3 +194,34 @@ pub fn prompt_secret(secret_format: SecretFormat) -> KeyPair { SecretFormat::Predefined => prompt_predefined().into(), } } + +/// get the secret from user, trying first keystore then input +pub fn fetch_or_get_keypair(data: &Data, address: Option<AccountId>) -> 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()); + }; + + // look for corresponding secret in keystore + if let Some(secret) = commands::vault::try_fetch_secret(data, address)? { + return get_keypair(SecretFormat::Substrate, Some(&secret)); + }; + } + // 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)) +} + +// catch known addresses +fn catch_known(address: &str) -> Option<&str> { + match address { + "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" => Some("Alice"), + "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty" => Some("Bob"), + "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" => Some("Charlie"), + "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" => Some("Dave"), + "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw" => Some("Eve"), + _ => None, + } +} diff --git a/src/main.rs b/src/main.rs index a36c699dcfb52467aa9662bdbe9daebb327679e1..98458a9e5e55994720c5012ec81cb9bba7a70b39 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,13 +40,13 @@ pub struct Args { /// Do not use indexer #[clap(long)] no_indexer: bool, - /// Secret key or BIP39 mnemonic + /// 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, cesium) - #[clap(short = 'S', long, default_value = SecretFormat::Substrate)] - secret_format: SecretFormat, + #[clap(short = 'S', long)] + secret_format: Option<SecretFormat>, /// Address #[clap(short, long)] address: Option<String>, @@ -137,6 +137,9 @@ pub enum Subcommand { /// Config (show, save...) #[clap(subcommand)] Config(conf::Subcommand), + /// Key management (import, generate, list...) + #[clap(subcommand)] + Vault(commands::vault::Subcommand), /// Cesium #[clap(subcommand, hide = true)] Cesium(commands::cesium::Subcommand), @@ -152,7 +155,7 @@ async fn main() -> Result<(), GcliError> { env_logger::init(); // parse argument and initialize data - let data = Data::new(Args::parse()); + let data = Data::new(Args::parse())?; // match subcommands let result = match data.args.subcommand.clone() { @@ -177,6 +180,7 @@ async fn main() -> Result<(), GcliError> { } Subcommand::Indexer(subcommand) => indexer::handle_command(data, subcommand).await, Subcommand::Config(subcommand) => conf::handle_command(data, subcommand), + Subcommand::Vault(subcommand) => commands::vault::handle_command(data, subcommand), Subcommand::Cesium(subcommand) => commands::cesium::handle_command(data, subcommand).await, Subcommand::Publish => commands::publish::handle_command().await, };