diff --git a/.github/workflows/binaries.yml b/.github/workflows/binaries.yml index 45d74a243..48e2e5657 100644 --- a/.github/workflows/binaries.yml +++ b/.github/workflows/binaries.yml @@ -46,7 +46,7 @@ jobs: - run: rustup target add ${{ matrix.sys.target }} - if: matrix.sys.target == 'aarch64-unknown-linux-gnu' - run: sudo apt-get update && sudo apt-get -y install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libudev-dev + run: sudo apt-get update && sudo apt-get -y install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libudev-dev libdbus-1-dev - name: Setup vars run: | @@ -69,6 +69,7 @@ jobs: env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc working-directory: ${{ env.BUILD_WORKING_DIR }} + run: sudo apt-get update && sudo apt-get -y install libdbus-1-dev run: cargo build --target-dir="$GITHUB_WORKSPACE/target" --package ${{ matrix.crate.name }} --features opt --release --target ${{ matrix.sys.target }} - name: Build provenance for attestation (release only) diff --git a/.github/workflows/bindings-ts.yml b/.github/workflows/bindings-ts.yml index f6aaae499..7f87baf08 100644 --- a/.github/workflows/bindings-ts.yml +++ b/.github/workflows/bindings-ts.yml @@ -38,6 +38,7 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: rustup update + - run: sudo apt install -y libdbus-1-dev - run: cargo build - run: rustup target add wasm32-unknown-unknown - run: make build-test-wasms diff --git a/.github/workflows/rpc-tests.yml b/.github/workflows/rpc-tests.yml index 75b6d7760..5392d9830 100644 --- a/.github/workflows/rpc-tests.yml +++ b/.github/workflows/rpc-tests.yml @@ -39,6 +39,7 @@ jobs: target/ key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - run: rustup update + - run: sudo apt install -y libdbus-1-dev - run: cargo build - run: rustup target add wasm32-unknown-unknown - run: make build-test-wasms diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f3c9b1920..ff4f7d399 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -49,6 +49,7 @@ jobs: - uses: actions/checkout@v4 - uses: stellar/actions/rust-cache@main - run: rustup update + - run: sudo apt install -y libdbus-1-dev - run: make generate-full-help-doc - run: git add -N . && git diff HEAD --exit-code @@ -90,7 +91,7 @@ jobs: - run: rustup target add ${{ matrix.sys.target }} - run: rustup target add wasm32-unknown-unknown - if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get -y install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libudev-dev + run: sudo apt-get update && sudo apt-get -y install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libudev-dev libdbus-1-dev - run: cargo clippy --all-targets --target ${{ matrix.sys.target }} - run: make test env: diff --git a/Cargo.lock b/Cargo.lock index 357f76e1b..9692c18bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1179,6 +1179,30 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8566979429cf69b49a5c740c60791108e86440e8be149bbea4fe54d2c32d6e2" +[[package]] +name = "dbus" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +dependencies = [ + "libc", + "libdbus-sys", + "winapi", +] + +[[package]] +name = "dbus-secret-service" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42a16374481d92aed73ae45b1f120207d8e71d24fb89f357fadbd8f946fd84b" +dependencies = [ + "dbus", + "futures-util", + "num", + "once_cell", + "rand", +] + [[package]] name = "der" version = "0.7.9" @@ -2581,6 +2605,18 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "keyring" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa83d1ca02db069b5fbe94b23b584d588e989218310c9c15015bb5571ef1a94" +dependencies = [ + "byteorder 1.5.0", + "dbus-secret-service", + "security-framework", + "windows-sys 0.59.0", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -2682,6 +2718,15 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +[[package]] +name = "libdbus-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" +dependencies = [ + "pkg-config", +] + [[package]] name = "libm" version = "0.2.8" @@ -2870,6 +2915,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.4" @@ -2881,6 +2940,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -2908,6 +2976,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -4320,6 +4411,7 @@ dependencies = [ "itertools 0.10.5", "jsonrpsee-core", "jsonrpsee-http-client", + "keyring", "mockito", "num-bigint", "open", @@ -4371,6 +4463,8 @@ dependencies = [ "wasm-opt", "wasmparser", "which", + "whoami", + "zeroize", ] [[package]] @@ -5589,6 +5683,12 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.92" @@ -5797,6 +5897,17 @@ dependencies = [ "rustix 0.38.34", ] +[[package]] +name = "whoami" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] + [[package]] name = "widestring" version = "1.1.0" diff --git a/FULL_HELP_DOCS.md b/FULL_HELP_DOCS.md index d923fc21b..87f32eafe 100644 --- a/FULL_HELP_DOCS.md +++ b/FULL_HELP_DOCS.md @@ -938,7 +938,7 @@ Create and manage identities including keys and addresses ###### **Subcommands:** -* `add` — Add a new identity (keypair, ledger, macOS keychain) +* `add` — Add a new identity (keypair, ledger, OS specific secure store) * `address` — Given an identity return its address (public key) * `fund` — Fund an identity on a test network * `generate` — Generate a new identity with a seed phrase, currently 12 words @@ -951,7 +951,7 @@ Create and manage identities including keys and addresses ## `stellar keys add` -Add a new identity (keypair, ledger, macOS keychain) +Add a new identity (keypair, ledger, OS specific secure store) **Usage:** `stellar keys add [OPTIONS] ` @@ -1024,6 +1024,7 @@ Generate a new identity with a seed phrase, currently 12 words * `--no-fund` — Do not fund address * `--seed ` — Optional seed to use when generating seed phrase. Random otherwise * `-s`, `--as-secret` — Output the generated identity as a secret key +* `--secure-store` — Save in OS-specific secure store * `--global` — Use global config * `--config-dir ` — Location of config directory, default is "." * `--hd-path ` — When generating a secret key, which `hd_path` should be used from the original `seed_phrase` diff --git a/cmd/crates/soroban-spec-typescript/ts-tests/src/util.ts b/cmd/crates/soroban-spec-typescript/ts-tests/src/util.ts index eacedbaec..47504c368 100644 --- a/cmd/crates/soroban-spec-typescript/ts-tests/src/util.ts +++ b/cmd/crates/soroban-spec-typescript/ts-tests/src/util.ts @@ -3,7 +3,7 @@ import { Address, Keypair } from "@stellar/stellar-sdk"; import { basicNodeSigner } from "@stellar/stellar-sdk/contract"; const rootKeypair = Keypair.fromSecret( - spawnSync("./soroban", ["keys", "secret", "root"], { + spawnSync("./stellar", ["keys", "secret", "root"], { shell: true, encoding: "utf8", }).stdout.trim(), diff --git a/cmd/crates/soroban-test/tests/it/config.rs b/cmd/crates/soroban-test/tests/it/config.rs index e81233b6e..2e5bc21c1 100644 --- a/cmd/crates/soroban-test/tests/it/config.rs +++ b/cmd/crates/soroban-test/tests/it/config.rs @@ -393,3 +393,47 @@ fn set_default_network() { .stdout(predicate::str::contains("STELLAR_NETWORK=testnet")) .success(); } + +#[test] +fn cannot_create_contract_with_test_name() { + let sandbox = TestEnv::default(); + sandbox + .new_assert_cmd("keys") + .arg("generate") + .arg("--no-fund") + .arg("d") + .assert() + .success(); + sandbox + .new_assert_cmd("contract") + .arg("alias") + .arg("add") + .arg("d") + .arg("--id=CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") + .assert() + .stderr(predicate::str::contains("cannot overlap with key")) + .failure(); +} + +#[test] +fn cannot_create_key_with_alias() { + let sandbox = TestEnv::default(); + sandbox + .new_assert_cmd("contract") + .arg("alias") + .arg("add") + .arg("t") + .arg("--id=CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") + .assert() + .success(); + sandbox + .new_assert_cmd("keys") + .arg("generate") + .arg("--no-fund") + .arg("t") + .assert() + .stderr(predicate::str::contains( + "cannot overlap with contract alias", + )) + .failure(); +} diff --git a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs index 6cdb61192..f4c2be61b 100644 --- a/cmd/crates/soroban-test/tests/it/integration/custom_types.rs +++ b/cmd/crates/soroban-test/tests/it/integration/custom_types.rs @@ -5,7 +5,7 @@ use soroban_test::TestEnv; use crate::integration::util::{deploy_custom, extend_contract}; -use super::util::invoke_with_roundtrip; +use super::util::{invoke, invoke_with_roundtrip}; fn invoke_custom(e: &TestEnv, id: &str, func: &str) -> assert_cmd::Command { let mut s = e.new_assert_cmd("contract"); @@ -40,7 +40,9 @@ async fn parse() { negative_i32(sandbox, id).await; negative_i64(sandbox, id).await; account_address(sandbox, id).await; + account_address_with_alias(sandbox, id).await; contract_address(sandbox, id).await; + contract_address_with_alias(sandbox, id).await; bytes(sandbox, id).await; const_enum(sandbox, id).await; number_arg_return_ok(sandbox, id); @@ -237,6 +239,12 @@ async fn account_address(sandbox: &TestEnv, id: &str) { .await; } +async fn account_address_with_alias(sandbox: &TestEnv, id: &str) { + let res = invoke(sandbox, id, "addresse", &json!("test").to_string()).await; + let test = format!("\"{}\"", super::tx::operations::test_address(sandbox)); + assert_eq!(test, res); +} + async fn contract_address(sandbox: &TestEnv, id: &str) { invoke_with_roundtrip( sandbox, @@ -247,6 +255,22 @@ async fn contract_address(sandbox: &TestEnv, id: &str) { .await; } +async fn contract_address_with_alias(sandbox: &TestEnv, id: &str) { + sandbox + .new_assert_cmd("contract") + .arg("alias") + .arg("add") + .arg("test_contract") + .arg("--id=CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE") + .assert() + .success(); + let res = invoke(sandbox, id, "addresse", &json!("test_contract").to_string()).await; + assert_eq!( + res, + "\"CA3D5KRYM6CB7OWQ6TWYRR3Z4T7GNZLKERYNZGGA5SOAOPIFY6YQGAXE\"" + ); +} + async fn bytes(sandbox: &TestEnv, id: &str) { invoke_with_roundtrip(sandbox, id, "bytes", json!("7374656c6c6172")).await; } diff --git a/cmd/crates/soroban-test/tests/it/integration/tx.rs b/cmd/crates/soroban-test/tests/it/integration/tx.rs index c3cd2693b..3fa85bc09 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx.rs @@ -4,7 +4,7 @@ use soroban_test::{AssertExt, TestEnv}; use crate::integration::util::{deploy_contract, DeployKind, HELLO_WORLD}; -mod operations; +pub mod operations; #[tokio::test] async fn simulate() { diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs b/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs index 575ef3ae1..b89bdb2bf 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx/operations.rs @@ -11,7 +11,7 @@ use crate::integration::{ util::{deploy_contract, DeployKind, HELLO_WORLD}, }; -fn test_address(sandbox: &TestEnv) -> String { +pub fn test_address(sandbox: &TestEnv) -> String { sandbox .new_assert_cmd("keys") .arg("address") @@ -98,7 +98,7 @@ async fn create_account_with_alias() { .assert() .success(); let test = test_address(sandbox); - let client = soroban_rpc::Client::new(&sandbox.rpc_url).unwrap(); + let client = sandbox.client(); let test_account = client.get_account(&test).await.unwrap(); println!("test account has a balance of {}", test_account.balance); let starting_balance = ONE_XLM * 100; @@ -124,7 +124,7 @@ async fn create_account_with_alias() { #[tokio::test] async fn payment_with_alias() { let sandbox = &TestEnv::new(); - let client = soroban_rpc::Client::new(&sandbox.rpc_url).unwrap(); + let client = sandbox.client(); let (test, test1) = setup_accounts(sandbox); let test_account = client.get_account(&test).await.unwrap(); println!("test account has a balance of {}", test_account.balance); @@ -240,7 +240,7 @@ async fn account_merge() { #[tokio::test] async fn account_merge_with_alias() { let sandbox = &TestEnv::new(); - let client = soroban_rpc::Client::new(&sandbox.rpc_url).unwrap(); + let client = sandbox.client(); let (test, test1) = setup_accounts(sandbox); let before = client.get_account(&test).await.unwrap(); let before1 = client.get_account(&test1).await.unwrap(); diff --git a/cmd/crates/soroban-test/tests/it/integration/util.rs b/cmd/crates/soroban-test/tests/it/integration/util.rs index 486b00a1b..fc7f824b6 100644 --- a/cmd/crates/soroban-test/tests/it/integration/util.rs +++ b/cmd/crates/soroban-test/tests/it/integration/util.rs @@ -11,16 +11,19 @@ pub const CUSTOM_TYPES: &Wasm = &Wasm::Custom("test-wasms", "test_custom_types") pub const CUSTOM_ACCOUNT: &Wasm = &Wasm::Custom("test-wasms", "test_custom_account"); pub const SWAP: &Wasm = &Wasm::Custom("test-wasms", "test_swap"); +pub async fn invoke(sandbox: &TestEnv, id: &str, func: &str, data: &str) -> String { + sandbox + .invoke_with_test(&["--id", id, "--", func, &format!("--{func}"), data]) + .await + .unwrap() +} pub async fn invoke_with_roundtrip(e: &TestEnv, id: &str, func: &str, data: D) where D: Display, { let data = data.to_string(); println!("{data}"); - let res = e - .invoke_with_test(&["--id", id, "--", func, &format!("--{func}"), &data]) - .await - .unwrap(); + let res = invoke(e, id, func, &data).await; assert_eq!(res, data); } diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index e6e205248..2b667b0b1 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -72,7 +72,8 @@ rand = "0.8.5" wasmparser = { workspace = true } sha2 = { workspace = true } csv = "1.1.6" -ed25519-dalek = { workspace = true } +# zeroize feature ensures that all sensitive data is zeroed out when dropped +ed25519-dalek = { workspace = true, features = ["zeroize"] } reqwest = { version = "0.12.7", default-features = false, features = [ "rustls-tls", "http2", @@ -124,8 +125,12 @@ fqdn = "0.3.12" open = "5.3.0" url = "2.5.2" wasm-gen = "0.1.4" +zeroize = "1.8.1" +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] } +whoami = "1.5.2" serde_with = "3.11.0" + [build-dependencies] crate-git-revision = "0.0.6" serde.workspace = true diff --git a/cmd/soroban-cli/src/commands/contract/arg_parsing.rs b/cmd/soroban-cli/src/commands/contract/arg_parsing.rs index 21fa2f383..80691daa1 100644 --- a/cmd/soroban-cli/src/commands/contract/arg_parsing.rs +++ b/cmd/soroban-cli/src/commands/contract/arg_parsing.rs @@ -9,12 +9,14 @@ use ed25519_dalek::SigningKey; use heck::ToKebabCase; use crate::xdr::{ - self, Hash, InvokeContractArgs, ScAddress, ScSpecEntry, ScSpecFunctionV0, ScSpecTypeDef, ScVal, - ScVec, + self, Hash, InvokeContractArgs, ScSpecEntry, ScSpecFunctionV0, ScSpecTypeDef, ScVal, ScVec, }; use crate::commands::txn_result::TxnResult; -use crate::config::{self}; +use crate::config::{ + self, + sc_address::{self, UnresolvedScAddress}, +}; use soroban_spec_tools::Spec; #[derive(thiserror::Error, Debug)] @@ -43,6 +45,10 @@ pub enum Error { MissingArgument(String), #[error("")] MissingFileArg(PathBuf), + #[error(transparent)] + ScAddress(#[from] sc_address::Error), + #[error(transparent)] + Config(#[from] config::Error), } pub fn build_host_function_parameters( @@ -80,18 +86,18 @@ pub fn build_host_function_parameters( .map(|i| { let name = i.name.to_utf8_string()?; if let Some(mut val) = matches_.get_raw(&name) { - let mut s = val.next().unwrap().to_string_lossy().to_string(); + let mut s = val + .next() + .unwrap() + .to_string_lossy() + .trim_matches('"') + .to_string(); if matches!(i.type_, ScSpecTypeDef::Address) { - let cmd = crate::commands::keys::address::Cmd { - name: s.clone(), - hd_path: Some(0), - locator: config.locator.clone(), - }; - if let Ok(address) = cmd.public_key() { - s = address.to_string(); - } - if let Ok(key) = cmd.private_key() { - signers.push(key); + let addr = resolve_address(&s, config)?; + let signer = resolve_signer(&s, config); + s = addr; + if let Some(signer) = signer { + signers.push(signer); } } spec.from_string(&s, &i.type_) @@ -125,7 +131,7 @@ pub fn build_host_function_parameters( }) .collect::, Error>>()?; - let contract_address_arg = ScAddress::Contract(Hash(contract_id.0)); + let contract_address_arg = xdr::ScAddress::Contract(Hash(contract_id.0)); let function_symbol_arg = function .try_into() .map_err(|()| Error::FunctionNameTooLong(function.clone()))?; @@ -246,3 +252,28 @@ pub fn output_to_string( } Ok(TxnResult::Res(res_str)) } + +fn resolve_address(addr_or_alias: &str, config: &config::Args) -> Result { + let sc_address: UnresolvedScAddress = addr_or_alias.parse().unwrap(); + let account = match sc_address { + UnresolvedScAddress::Resolved(addr) => addr.to_string(), + addr @ UnresolvedScAddress::Alias(_) => { + let addr = addr.resolve(&config.locator, &config.get_network()?.network_passphrase)?; + match addr { + xdr::ScAddress::Account(account) => account.to_string(), + contract @ xdr::ScAddress::Contract(_) => contract.to_string(), + } + } + }; + Ok(account) +} + +fn resolve_signer(addr_or_alias: &str, config: &config::Args) -> Option { + config + .locator + .read_key(addr_or_alias) + .ok()? + .private_key(None) + .ok() + .map(|pk| SigningKey::from_bytes(&pk.0)) +} diff --git a/cmd/soroban-cli/src/commands/contract/fetch.rs b/cmd/soroban-cli/src/commands/contract/fetch.rs index 31ed191ff..d73aac3b7 100644 --- a/cmd/soroban-cli/src/commands/contract/fetch.rs +++ b/cmd/soroban-cli/src/commands/contract/fetch.rs @@ -22,7 +22,7 @@ use crate::{ pub struct Cmd { /// Contract ID to fetch #[arg(long = "id", env = "STELLAR_CONTRACT_ID")] - pub contract_id: config::ContractAddress, + pub contract_id: config::UnresolvedContract, /// Where to write output otherwise stdout is used #[arg(long, short = 'o')] pub out_file: Option, diff --git a/cmd/soroban-cli/src/commands/contract/info/shared.rs b/cmd/soroban-cli/src/commands/contract/info/shared.rs index 6023b03cf..13355268f 100644 --- a/cmd/soroban-cli/src/commands/contract/info/shared.rs +++ b/cmd/soroban-cli/src/commands/contract/info/shared.rs @@ -47,7 +47,7 @@ pub struct Args { conflicts_with = "wasm", conflicts_with = "wasm_hash" )] - pub contract_id: Option, + pub contract_id: Option, #[command(flatten)] pub network: network::Args, #[command(flatten)] diff --git a/cmd/soroban-cli/src/commands/contract/invoke.rs b/cmd/soroban-cli/src/commands/contract/invoke.rs index c7b631343..bd069698d 100644 --- a/cmd/soroban-cli/src/commands/contract/invoke.rs +++ b/cmd/soroban-cli/src/commands/contract/invoke.rs @@ -40,7 +40,7 @@ use soroban_spec_tools::contract; pub struct Cmd { /// Contract ID to invoke #[arg(long = "id", env = "STELLAR_CONTRACT_ID")] - pub contract_id: config::ContractAddress, + pub contract_id: config::UnresolvedContract, // For testing only #[arg(skip)] pub wasm: Option, diff --git a/cmd/soroban-cli/src/commands/events.rs b/cmd/soroban-cli/src/commands/events.rs index 48d79c1b7..16ef410bd 100644 --- a/cmd/soroban-cli/src/commands/events.rs +++ b/cmd/soroban-cli/src/commands/events.rs @@ -42,7 +42,7 @@ pub struct Cmd { num_args = 1..=6, help_heading = "FILTERS" )] - contract_ids: Vec, + contract_ids: Vec, /// A set of (up to 4) topic filters to filter event topics on. A single /// topic filter can contain 1-4 different segment filters, separated by /// commas, with an asterisk (`*` character) indicating a wildcard segment. diff --git a/cmd/soroban-cli/src/commands/keys/add.rs b/cmd/soroban-cli/src/commands/keys/add.rs index a29286b0a..265b090f6 100644 --- a/cmd/soroban-cli/src/commands/keys/add.rs +++ b/cmd/soroban-cli/src/commands/keys/add.rs @@ -2,7 +2,7 @@ use clap::command; use crate::{ commands::global, - config::{key, locator, secret}, + config::{address::KeyName, key, locator, secret}, print::Print, }; @@ -20,7 +20,7 @@ pub enum Error { #[group(skip)] pub struct Cmd { /// Name of identity - pub name: String, + pub name: KeyName, #[command(flatten)] pub secrets: secret::Args, diff --git a/cmd/soroban-cli/src/commands/keys/address.rs b/cmd/soroban-cli/src/commands/keys/address.rs index 8eda8dd83..51ce90ed2 100644 --- a/cmd/soroban-cli/src/commands/keys/address.rs +++ b/cmd/soroban-cli/src/commands/keys/address.rs @@ -1,24 +1,21 @@ use clap::arg; -use crate::commands::config::{key, locator}; +use crate::{ + commands::config::{address, locator}, + config::UnresolvedMuxedAccount, +}; #[derive(thiserror::Error, Debug)] pub enum Error { #[error(transparent)] - Config(#[from] locator::Error), - - #[error(transparent)] - Key(#[from] key::Error), - - #[error(transparent)] - StrKey(#[from] stellar_strkey::DecodeError), + Address(#[from] address::Error), } #[derive(Debug, clap::Parser, Clone)] #[group(skip)] pub struct Cmd { /// Name of identity to lookup, default test identity used if not provided - pub name: String, + pub name: UnresolvedMuxedAccount, /// If identity is a seed phrase use this hd path, default is 0 #[arg(long)] @@ -34,23 +31,14 @@ impl Cmd { Ok(()) } - pub fn private_key(&self) -> Result { - Ok(ed25519_dalek::SigningKey::from_bytes( - &self - .locator - .read_identity(&self.name)? - .private_key(self.hd_path)? - .0, - )) - } - pub fn public_key(&self) -> Result { - if let Ok(key) = stellar_strkey::ed25519::PublicKey::from_string(&self.name) { - Ok(key) - } else { - Ok(stellar_strkey::ed25519::PublicKey::from_payload( - self.private_key()?.verifying_key().as_bytes(), - )?) - } + let muxed = self + .name + .resolve_muxed_account(&self.locator, self.hd_path)?; + let bytes = match muxed { + soroban_sdk::xdr::MuxedAccount::Ed25519(uint256) => uint256.0, + soroban_sdk::xdr::MuxedAccount::MuxedEd25519(muxed_account) => muxed_account.ed25519.0, + }; + Ok(stellar_strkey::ed25519::PublicKey(bytes)) } } diff --git a/cmd/soroban-cli/src/commands/keys/generate.rs b/cmd/soroban-cli/src/commands/keys/generate.rs index fda6a3d98..b731c8c02 100644 --- a/cmd/soroban-cli/src/commands/keys/generate.rs +++ b/cmd/soroban-cli/src/commands/keys/generate.rs @@ -1,10 +1,16 @@ use clap::{arg, command}; +use sep5::SeedPhrase; use super::super::config::{ locator, network, secret::{self, Secret}, }; -use crate::{commands::global, print::Print}; +use crate::{ + commands::global, + config::address::KeyName, + print::Print, + signer::keyring::{self, StellarEntry}, +}; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -19,6 +25,9 @@ pub enum Error { #[error("An identity with the name '{0}' already exists")] IdentityAlreadyExists(String), + + #[error(transparent)] + Keyring(#[from] keyring::Error), } #[derive(Debug, clap::Parser, Clone)] @@ -26,10 +35,12 @@ pub enum Error { #[allow(clippy::struct_excessive_bools)] pub struct Cmd { /// Name of identity - pub name: String, + pub name: KeyName, + /// Do not fund address #[arg(long)] pub no_fund: bool, + /// Optional seed to use when generating seed phrase. /// Random otherwise. #[arg(long, conflicts_with = "default_seed")] @@ -39,6 +50,10 @@ pub struct Cmd { #[arg(long, short = 's')] pub as_secret: bool, + /// Save in OS-specific secure store + #[arg(long)] + pub secure_store: bool, + #[command(flatten)] pub config_locator: locator::Args, @@ -69,10 +84,10 @@ impl Cmd { if self.config_locator.read_identity(&self.name).is_ok() { if !self.overwrite { - return Err(Error::IdentityAlreadyExists(self.name.clone())); + return Err(Error::IdentityAlreadyExists(self.name.to_string())); } - print.exclaimln(format!("Overwriting identity '{}'", &self.name)); + print.exclaimln(format!("Overwriting identity '{}'", &self.name.to_string())); } if !self.fund { @@ -83,19 +98,7 @@ impl Cmd { warning. It can be suppressed with -q flag.", ); } - - let seed_phrase = if self.default_seed { - Secret::test_seed_phrase() - } else { - Secret::from_seed(self.seed.as_deref()) - }?; - - let secret = if self.as_secret { - seed_phrase.private_key(self.hd_path)?.into() - } else { - seed_phrase - }; - + let secret = self.secret(&print)?; let path = self.config_locator.write_identity(&self.name, &secret)?; print.checkln(format!("Key saved with alias {:?} in {path:?}", self.name)); @@ -117,4 +120,131 @@ impl Cmd { Ok(()) } + + fn secret(&self, print: &Print) -> Result { + let seed_phrase = self.seed_phrase()?; + if self.secure_store { + // secure_store:org.stellar.cli: + let entry_name_with_prefix = format!( + "{}{}-{}", + keyring::SECURE_STORE_ENTRY_PREFIX, + keyring::SECURE_STORE_ENTRY_SERVICE, + self.name + ); + + //checking that the entry name is valid before writing to the secure store + let secret: Secret = entry_name_with_prefix.parse()?; + + if let Secret::SecureStore { entry_name } = &secret { + Self::write_to_secure_store(entry_name, seed_phrase, print)?; + } + + return Ok(secret); + } + let secret: Secret = seed_phrase.into(); + Ok(if self.as_secret { + secret.private_key(self.hd_path)?.into() + } else { + secret + }) + } + + fn seed_phrase(&self) -> Result { + Ok(if self.default_seed { + secret::test_seed_phrase() + } else { + secret::seed_phrase_from_seed(self.seed.as_deref()) + }?) + } + + fn write_to_secure_store( + entry_name: &String, + seed_phrase: SeedPhrase, + print: &Print, + ) -> Result<(), Error> { + print.infoln(format!("Writing to secure store: {entry_name}")); + let entry = StellarEntry::new(entry_name)?; + if let Ok(key) = entry.get_public_key(None) { + print.warnln(format!("A key for {entry_name} already exists in your operating system's secure store: {key}")); + } else { + print.infoln(format!( + "Saving a new key to your operating system's secure store: {entry_name}" + )); + entry.set_seed_phrase(seed_phrase)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::config::{address::KeyName, key::Key, secret::Secret}; + use keyring::{mock, set_default_credential_builder}; + + fn set_up_test() -> (super::locator::Args, super::Cmd) { + let temp_dir = tempfile::tempdir().unwrap(); + let locator = super::locator::Args { + global: false, + config_dir: Some(temp_dir.path().to_path_buf()), + }; + + let cmd = super::Cmd { + name: KeyName("test_name".to_string()), + no_fund: true, + seed: None, + as_secret: false, + secure_store: false, + config_locator: locator.clone(), + hd_path: None, + default_seed: false, + network: super::network::Args::default(), + fund: false, + overwrite: false, + }; + + (locator, cmd) + } + + fn global_args() -> super::global::Args { + super::global::Args { + quiet: true, + ..Default::default() + } + } + + #[tokio::test] + async fn test_storing_secret_as_a_seed_phrase() { + let (test_locator, cmd) = set_up_test(); + let global_args = global_args(); + + let result = cmd.run(&global_args).await; + assert!(result.is_ok()); + let identity = test_locator.read_identity("test_name").unwrap(); + assert!(matches!(identity, Key::Secret(Secret::SeedPhrase { .. }))); + } + + #[tokio::test] + async fn test_storing_secret_as_a_secret_key() { + let (test_locator, mut cmd) = set_up_test(); + cmd.as_secret = true; + let global_args = global_args(); + + let result = cmd.run(&global_args).await; + assert!(result.is_ok()); + let identity = test_locator.read_identity("test_name").unwrap(); + assert!(matches!(identity, Key::Secret(Secret::SecretKey { .. }))); + } + + #[tokio::test] + async fn test_storing_secret_in_secure_store() { + set_default_credential_builder(mock::default_credential_builder()); + let (test_locator, mut cmd) = set_up_test(); + cmd.secure_store = true; + let global_args = global_args(); + + let result = cmd.run(&global_args).await; + assert!(result.is_ok()); + let identity = test_locator.read_identity("test_name").unwrap(); + assert!(matches!(identity, Key::Secret(Secret::SecureStore { .. }))); + } } diff --git a/cmd/soroban-cli/src/commands/keys/mod.rs b/cmd/soroban-cli/src/commands/keys/mod.rs index b5520abf6..3e36df085 100644 --- a/cmd/soroban-cli/src/commands/keys/mod.rs +++ b/cmd/soroban-cli/src/commands/keys/mod.rs @@ -12,7 +12,7 @@ pub mod secret; #[derive(Debug, Parser)] pub enum Cmd { - /// Add a new identity (keypair, ledger, macOS keychain) + /// Add a new identity (keypair, ledger, OS specific secure store) Add(add::Cmd), /// Given an identity return its address (public key) diff --git a/cmd/soroban-cli/src/commands/snapshot/create.rs b/cmd/soroban-cli/src/commands/snapshot/create.rs index a3ba865fa..9ad39953f 100644 --- a/cmd/soroban-cli/src/commands/snapshot/create.rs +++ b/cmd/soroban-cli/src/commands/snapshot/create.rs @@ -34,7 +34,7 @@ use crate::{ tx::builder, utils::get_name_from_stellar_asset_contract_storage, }; -use crate::{config::address::Address, utils::http}; +use crate::{config::address::UnresolvedMuxedAccount, utils::http}; #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum)] pub enum Output { @@ -413,7 +413,7 @@ impl Cmd { // Resolve an account address to an account id. The address can be a // G-address or a key name (as in `stellar keys address NAME`). fn resolve_account(&self, address: &str) -> Option { - let address: Address = address.parse().ok()?; + let address: UnresolvedMuxedAccount = address.parse().ok()?; Some(AccountId(xdr::PublicKey::PublicKeyTypeEd25519( match address.resolve_muxed_account(&self.locator, None).ok()? { diff --git a/cmd/soroban-cli/src/commands/tx/args.rs b/cmd/soroban-cli/src/commands/tx/args.rs index 5d2099d26..02ba7a922 100644 --- a/cmd/soroban-cli/src/commands/tx/args.rs +++ b/cmd/soroban-cli/src/commands/tx/args.rs @@ -2,7 +2,7 @@ use crate::{ commands::{global, txn_result::TxnEnvelopeResult}, config::{ self, - address::{self, Address}, + address::{self, UnresolvedMuxedAccount}, data, network, secret, }, fee, @@ -113,11 +113,17 @@ impl Args { Ok(self.config.source_account()?) } - pub fn resolve_muxed_address(&self, address: &Address) -> Result { + pub fn resolve_muxed_address( + &self, + address: &UnresolvedMuxedAccount, + ) -> Result { Ok(address.resolve_muxed_account(&self.config.locator, self.config.hd_path)?) } - pub fn resolve_account_id(&self, address: &Address) -> Result { + pub fn resolve_account_id( + &self, + address: &UnresolvedMuxedAccount, + ) -> Result { Ok(address .resolve_muxed_account(&self.config.locator, self.config.hd_path)? .account_id()) @@ -127,7 +133,7 @@ impl Args { &self, op_body: impl Into, tx_env: xdr::TransactionEnvelope, - op_source: Option<&address::Address>, + op_source: Option<&address::UnresolvedMuxedAccount>, ) -> Result { let source_account = op_source .map(|a| self.resolve_muxed_address(a)) diff --git a/cmd/soroban-cli/src/commands/tx/new/account_merge.rs b/cmd/soroban-cli/src/commands/tx/new/account_merge.rs index c087c6b13..a5cf25ce6 100644 --- a/cmd/soroban-cli/src/commands/tx/new/account_merge.rs +++ b/cmd/soroban-cli/src/commands/tx/new/account_merge.rs @@ -15,7 +15,7 @@ pub struct Cmd { pub struct Args { /// Muxed Account to merge with, e.g. `GBX...`, 'MBX...' #[arg(long)] - pub account: address::Address, + pub account: address::UnresolvedMuxedAccount, } impl TryFrom<&Cmd> for xdr::OperationBody { diff --git a/cmd/soroban-cli/src/commands/tx/new/create_account.rs b/cmd/soroban-cli/src/commands/tx/new/create_account.rs index 13bcac4d4..b892646b5 100644 --- a/cmd/soroban-cli/src/commands/tx/new/create_account.rs +++ b/cmd/soroban-cli/src/commands/tx/new/create_account.rs @@ -15,7 +15,7 @@ pub struct Cmd { pub struct Args { /// Account Id to create, e.g. `GBX...` #[arg(long)] - pub destination: address::Address, + pub destination: address::UnresolvedMuxedAccount, /// Initial balance in stroops of the account, default 1 XLM #[arg(long, default_value = "10_000_000")] pub starting_balance: builder::Amount, diff --git a/cmd/soroban-cli/src/commands/tx/new/payment.rs b/cmd/soroban-cli/src/commands/tx/new/payment.rs index 3599d7010..1e58110b0 100644 --- a/cmd/soroban-cli/src/commands/tx/new/payment.rs +++ b/cmd/soroban-cli/src/commands/tx/new/payment.rs @@ -15,7 +15,7 @@ pub struct Cmd { pub struct Args { /// Account to send to, e.g. `GBX...` #[arg(long)] - pub destination: address::Address, + pub destination: address::UnresolvedMuxedAccount, /// Asset to send, default native, e.i. XLM #[arg(long, default_value = "native")] pub asset: builder::Asset, diff --git a/cmd/soroban-cli/src/commands/tx/new/set_options.rs b/cmd/soroban-cli/src/commands/tx/new/set_options.rs index cf38bf574..87e0d2423 100644 --- a/cmd/soroban-cli/src/commands/tx/new/set_options.rs +++ b/cmd/soroban-cli/src/commands/tx/new/set_options.rs @@ -16,7 +16,7 @@ pub struct Cmd { pub struct Args { #[arg(long)] /// Account of the inflation destination. - pub inflation_dest: Option, + pub inflation_dest: Option, #[arg(long)] /// A number from 0-255 (inclusive) representing the weight of the master key. If the weight of the master key is updated to 0, it is effectively disabled. pub master_weight: Option, diff --git a/cmd/soroban-cli/src/commands/tx/new/set_trustline_flags.rs b/cmd/soroban-cli/src/commands/tx/new/set_trustline_flags.rs index 7a175915e..ac2830222 100644 --- a/cmd/soroban-cli/src/commands/tx/new/set_trustline_flags.rs +++ b/cmd/soroban-cli/src/commands/tx/new/set_trustline_flags.rs @@ -16,7 +16,7 @@ pub struct Cmd { pub struct Args { /// Account to set trustline flags for, e.g. `GBX...`, or alias, or muxed account, `M123...`` #[arg(long)] - pub trustor: address::Address, + pub trustor: address::UnresolvedMuxedAccount, /// Asset to set trustline flags for #[arg(long)] pub asset: builder::Asset, diff --git a/cmd/soroban-cli/src/commands/tx/op/add/args.rs b/cmd/soroban-cli/src/commands/tx/op/add/args.rs index 9d9b4931b..0dd195f7f 100644 --- a/cmd/soroban-cli/src/commands/tx/op/add/args.rs +++ b/cmd/soroban-cli/src/commands/tx/op/add/args.rs @@ -9,7 +9,7 @@ pub struct Args { visible_alias = "op-source", env = "STELLAR_OPERATION_SOURCE_ACCOUNT" )] - pub operation_source_account: Option, + pub operation_source_account: Option, } impl Args { diff --git a/cmd/soroban-cli/src/config/address.rs b/cmd/soroban-cli/src/config/address.rs index 6975d9e53..202569471 100644 --- a/cmd/soroban-cli/src/config/address.rs +++ b/cmd/soroban-cli/src/config/address.rs @@ -1,4 +1,7 @@ -use std::str::FromStr; +use std::{ + fmt::{self, Display, Formatter}, + str::FromStr, +}; use crate::xdr; @@ -6,14 +9,14 @@ use super::{key, locator, secret}; /// Address can be either a public key or eventually an alias of a address. #[derive(Clone, Debug)] -pub enum Address { - MuxedAccount(xdr::MuxedAccount), +pub enum UnresolvedMuxedAccount { + Resolved(xdr::MuxedAccount), AliasOrSecret(String), } -impl Default for Address { +impl Default for UnresolvedMuxedAccount { fn default() -> Self { - Address::AliasOrSecret(String::default()) + UnresolvedMuxedAccount::AliasOrSecret(String::default()) } } @@ -25,37 +28,83 @@ pub enum Error { Key(#[from] key::Error), #[error("Address cannot be used to sign {0}")] CannotSign(xdr::MuxedAccount), + #[error("Invalid key name: {0}\n only alphanumeric characters, underscores (_), and hyphens (-) are allowed.")] + InvalidKeyNameCharacters(String), + #[error("Invalid key name: {0}\n keys cannot exceed 250 characters")] + InvalidKeyNameLength(String), + #[error("Invalid key name: {0}\n keys cannot be the word \"ledger\"")] + InvalidKeyName(String), } -impl FromStr for Address { +impl FromStr for UnresolvedMuxedAccount { type Err = Error; fn from_str(value: &str) -> Result { Ok(xdr::MuxedAccount::from_str(value).map_or_else( - |_| Address::AliasOrSecret(value.to_string()), - Address::MuxedAccount, + |_| UnresolvedMuxedAccount::AliasOrSecret(value.to_string()), + UnresolvedMuxedAccount::Resolved, )) } } -impl Address { +impl UnresolvedMuxedAccount { pub fn resolve_muxed_account( &self, locator: &locator::Args, hd_path: Option, ) -> Result { match self { - Address::MuxedAccount(muxed_account) => Ok(muxed_account.clone()), - Address::AliasOrSecret(alias) => alias - .parse() - .or_else(|_| Ok(locator.get_public_key(alias, hd_path)?)), + UnresolvedMuxedAccount::Resolved(muxed_account) => Ok(muxed_account.clone()), + UnresolvedMuxedAccount::AliasOrSecret(alias_or_secret) => { + Ok(locator.read_key(alias_or_secret)?.public_key(hd_path)?) + } } } pub fn resolve_secret(&self, locator: &locator::Args) -> Result { match &self { - Address::AliasOrSecret(alias) => Ok(locator.get_secret_key(alias)?), - Address::MuxedAccount(muxed_account) => Err(Error::CannotSign(muxed_account.clone())), + UnresolvedMuxedAccount::Resolved(muxed_account) => { + Err(Error::CannotSign(muxed_account.clone())) + } + UnresolvedMuxedAccount::AliasOrSecret(alias_or_secret) => { + Ok(locator.read_key(alias_or_secret)?.try_into()?) + } } } } + +#[derive(Clone, Debug)] +pub struct KeyName(pub String); + +impl std::ops::Deref for KeyName { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::str::FromStr for KeyName { + type Err = Error; + fn from_str(s: &str) -> Result { + if !s.chars().all(allowed_char) { + return Err(Error::InvalidKeyNameCharacters(s.to_string())); + } + if s == "ledger" { + return Err(Error::InvalidKeyName(s.to_string())); + } + if s.len() > 250 { + return Err(Error::InvalidKeyNameLength(s.to_string())); + } + Ok(KeyName(s.to_string())) + } +} + +impl Display for KeyName { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +fn allowed_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '-' +} diff --git a/cmd/soroban-cli/src/config/alias.rs b/cmd/soroban-cli/src/config/alias.rs index 9d1d8c11b..734925c4e 100644 --- a/cmd/soroban-cli/src/config/alias.rs +++ b/cmd/soroban-cli/src/config/alias.rs @@ -11,39 +11,49 @@ pub struct Data { /// Address can be either a contract address, C.. or eventually an alias of a contract address. #[derive(Clone, Debug)] -pub enum ContractAddress { - ContractId(stellar_strkey::Contract), +pub enum UnresolvedContract { + Resolved(stellar_strkey::Contract), Alias(String), } -impl Default for ContractAddress { +impl Default for UnresolvedContract { fn default() -> Self { - ContractAddress::Alias(String::default()) + UnresolvedContract::Alias(String::default()) } } -impl FromStr for ContractAddress { +impl FromStr for UnresolvedContract { type Err = Infallible; fn from_str(value: &str) -> Result { Ok(stellar_strkey::Contract::from_str(value).map_or_else( - |_| ContractAddress::Alias(value.to_string()), - ContractAddress::ContractId, + |_| UnresolvedContract::Alias(value.to_string()), + UnresolvedContract::Resolved, )) } } -impl ContractAddress { +impl UnresolvedContract { pub fn resolve_contract_id( &self, locator: &locator::Args, network_passphrase: &str, ) -> Result { match self { - ContractAddress::ContractId(muxed_account) => Ok(*muxed_account), - ContractAddress::Alias(alias) => locator - .get_contract_id(alias, network_passphrase)? - .ok_or_else(|| locator::Error::ContractNotFound(alias.to_owned())), + UnresolvedContract::Resolved(contract) => Ok(*contract), + UnresolvedContract::Alias(alias) => { + Self::resolve_alias(alias, locator, network_passphrase) + } } } + + pub fn resolve_alias( + alias: &str, + locator: &locator::Args, + network_passphrase: &str, + ) -> Result { + locator + .get_contract_id(alias, network_passphrase)? + .ok_or_else(|| locator::Error::ContractNotFound(alias.to_owned())) + } } diff --git a/cmd/soroban-cli/src/config/key.rs b/cmd/soroban-cli/src/config/key.rs index cecb83522..add554f96 100644 --- a/cmd/soroban-cli/src/config/key.rs +++ b/cmd/soroban-cli/src/config/key.rs @@ -130,6 +130,17 @@ impl Display for MuxedAccount { } } +impl TryFrom for Secret { + type Error = Error; + + fn try_from(key: Key) -> Result { + match key { + Key::Secret(secret) => Ok(secret), + _ => Err(Error::SecretPublicKey), + } + } +} + #[cfg(test)] mod test { use super::*; diff --git a/cmd/soroban-cli/src/config/locator.rs b/cmd/soroban-cli/src/config/locator.rs index 34f99ec69..e43c19972 100644 --- a/cmd/soroban-cli/src/config/locator.rs +++ b/cmd/soroban-cli/src/config/locator.rs @@ -84,6 +84,10 @@ pub enum Error { UpgradeCheckReadFailed { path: PathBuf, error: io::Error }, #[error("Failed to write upgrade check file: {path}: {error}")] UpgradeCheckWriteFailed { path: PathBuf, error: io::Error }, + #[error("Contract alias {0}, cannot overlap with key")] + ContractAliasCannotOverlapWithKey(String), + #[error("Key cannot {0} cannot overlap with contract alias")] + KeyCannotOverlapWithContractAlias(String), #[error("Only private keys and seed phrases are supported for getting private keys {0}")] SecretKeyOnly(String), #[error(transparent)] @@ -168,7 +172,10 @@ impl Args { } pub fn write_identity(&self, name: &str, secret: &Secret) -> Result { - self.write_key(name, &Key::Secret(secret.clone())) + if let Ok(Some(_)) = self.load_contract_from_alias(name) { + return Err(Error::KeyCannotOverlapWithContractAlias(name.to_owned())); + } + KeyType::Identity.write(name, secret, &self.config_dir()?) } pub fn write_public_key( @@ -316,6 +323,9 @@ impl Args { contract_id: &stellar_strkey::Contract, alias: &str, ) -> Result<(), Error> { + if self.read_identity(alias).is_ok() { + return Err(Error::ContractAliasCannotOverlapWithKey(alias.to_owned())); + } let path = self.alias_path(alias)?; let dir = path.parent().ok_or(Error::CannotAccessConfigDir)?; diff --git a/cmd/soroban-cli/src/config/mod.rs b/cmd/soroban-cli/src/config/mod.rs index fb4c787fd..866a654cc 100644 --- a/cmd/soroban-cli/src/config/mod.rs +++ b/cmd/soroban-cli/src/config/mod.rs @@ -1,4 +1,3 @@ -use address::Address; use clap::{arg, command}; use serde::{Deserialize, Serialize}; use std::{ @@ -20,11 +19,14 @@ pub mod data; pub mod key; pub mod locator; pub mod network; +pub mod sc_address; pub mod secret; pub mod sign_with; pub mod upgrade_check; -pub use alias::ContractAddress; +pub use address::UnresolvedMuxedAccount; +pub use alias::UnresolvedContract; +pub use sc_address::UnresolvedScAddress; #[derive(thiserror::Error, Debug)] pub enum Error { @@ -57,7 +59,7 @@ pub struct Args { /// or a seed phrase (--source "kite urban…"). /// If `--build-only` or `--sim-only` flags were NOT provided, this key will also be used to /// sign the final transaction. In that case, trying to sign with public key will fail. - pub source_account: Address, + pub source_account: UnresolvedMuxedAccount, #[arg(long)] /// If using a seed phrase, which hierarchical deterministic path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` diff --git a/cmd/soroban-cli/src/config/sc_address.rs b/cmd/soroban-cli/src/config/sc_address.rs new file mode 100644 index 000000000..c66b0c13c --- /dev/null +++ b/cmd/soroban-cli/src/config/sc_address.rs @@ -0,0 +1,66 @@ +use std::str::FromStr; + +use crate::xdr; + +use super::{address, locator, UnresolvedContract}; + +/// `ScAddress` can be either a resolved `xdr::ScAddress` or an alias of a `Contract` or `MuxedAccount`. +#[allow(clippy::module_name_repetitions)] +#[derive(Clone, Debug)] +pub enum UnresolvedScAddress { + Resolved(xdr::ScAddress), + Alias(String), +} + +impl Default for UnresolvedScAddress { + fn default() -> Self { + UnresolvedScAddress::Alias(String::default()) + } +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), + #[error(transparent)] + Address(#[from] address::Error), + #[error("Account alias not Found{0}")] + AccountAliasNotFound(String), +} + +impl FromStr for UnresolvedScAddress { + type Err = Error; + + fn from_str(value: &str) -> Result { + Ok(xdr::ScAddress::from_str(value).map_or_else( + |_| UnresolvedScAddress::Alias(value.to_string()), + UnresolvedScAddress::Resolved, + )) + } +} + +impl UnresolvedScAddress { + pub fn resolve( + self, + locator: &locator::Args, + network_passphrase: &str, + ) -> Result { + let alias = match self { + UnresolvedScAddress::Resolved(addr) => return Ok(addr), + UnresolvedScAddress::Alias(alias) => alias, + }; + let contract = UnresolvedContract::resolve_alias(&alias, locator, network_passphrase); + let muxed_account = locator.read_key(&alias)?.public_key(None); + match (contract, muxed_account) { + (Ok(contract), Ok(_)) => { + eprintln!( + "Warning: ScAddress alias {alias} is ambiguous, assuming it is a contract" + ); + Ok(xdr::ScAddress::Contract(xdr::Hash(contract.0))) + } + (Ok(contract), _) => Ok(xdr::ScAddress::Contract(xdr::Hash(contract.0))), + (_, Ok(muxed_account)) => Ok(xdr::ScAddress::Account(muxed_account.account_id())), + _ => Err(Error::AccountAliasNotFound(alias)), + } + } +} diff --git a/cmd/soroban-cli/src/config/secret.rs b/cmd/soroban-cli/src/config/secret.rs index a382d6bfa..09c33b8d5 100644 --- a/cmd/soroban-cli/src/config/secret.rs +++ b/cmd/soroban-cli/src/config/secret.rs @@ -1,11 +1,13 @@ use clap::arg; use serde::{Deserialize, Serialize}; use std::{io::Write, str::FromStr}; + +use sep5::SeedPhrase; use stellar_strkey::ed25519::{PrivateKey, PublicKey}; use crate::{ print::Print, - signer::{self, LocalKey, Signer, SignerKind}, + signer::{self, keyring, LocalKey, SecureStoreEntry, Signer, SignerKind}, utils, }; @@ -27,6 +29,10 @@ pub enum Error { InvalidSecretOrSeedPhrase, #[error(transparent)] Signer(#[from] signer::Error), + #[error(transparent)] + Keyring(#[from] keyring::Error), + #[error("Secure Store does not reveal secret key")] + SecureStoreDoesNotRevealSecretKey, } #[derive(Debug, clap::Args, Clone)] @@ -59,6 +65,7 @@ impl Args { pub enum Secret { SecretKey { secret_key: String }, SeedPhrase { seed_phrase: String }, + SecureStore { entry_name: String }, } impl FromStr for Secret { @@ -73,6 +80,10 @@ impl FromStr for Secret { Ok(Secret::SeedPhrase { seed_phrase: s.to_string(), }) + } else if s.starts_with(keyring::SECURE_STORE_ENTRY_PREFIX) { + Ok(Secret::SecureStore { + entry_name: s.to_string(), + }) } else { Err(Error::InvalidSecretOrSeedPhrase) } @@ -93,6 +104,14 @@ impl From for Key { } } +impl From for Secret { + fn from(value: SeedPhrase) -> Self { + Secret::SeedPhrase { + seed_phrase: value.seed_phrase.into_phrase(), + } + } +} + impl Secret { pub fn private_key(&self, index: Option) -> Result { Ok(match self { @@ -103,22 +122,34 @@ impl Secret { .private() .0, )?, + Secret::SecureStore { .. } => { + return Err(Error::SecureStoreDoesNotRevealSecretKey); + } }) } pub fn public_key(&self, index: Option) -> Result { - let key = self.key_pair(index)?; - Ok(stellar_strkey::ed25519::PublicKey::from_payload( - key.verifying_key().as_bytes(), - )?) + if let Secret::SecureStore { entry_name } = self { + let entry = keyring::StellarEntry::new(entry_name)?; + Ok(entry.get_public_key(index)?) + } else { + let key = self.key_pair(index)?; + Ok(stellar_strkey::ed25519::PublicKey::from_payload( + key.verifying_key().as_bytes(), + )?) + } } - pub fn signer(&self, index: Option, print: Print) -> Result { + pub fn signer(&self, hd_path: Option, print: Print) -> Result { let kind = match self { Secret::SecretKey { .. } | Secret::SeedPhrase { .. } => { - let key = self.key_pair(index)?; + let key = self.key_pair(hd_path)?; SignerKind::Local(LocalKey { key }) } + Secret::SecureStore { entry_name } => SignerKind::SecureStore(SecureStoreEntry { + name: entry_name.to_string(), + hd_path, + }), }; Ok(Signer { kind, print }) } @@ -128,14 +159,7 @@ impl Secret { } pub fn from_seed(seed: Option<&str>) -> Result { - let seed_phrase = if let Some(seed) = seed.map(str::as_bytes) { - sep5::SeedPhrase::from_entropy(seed) - } else { - sep5::SeedPhrase::random(sep5::MnemonicType::Words24) - }? - .seed_phrase - .into_phrase(); - Ok(Secret::SeedPhrase { seed_phrase }) + Ok(seed_phrase_from_seed(seed)?.into()) } pub fn test_seed_phrase() -> Result { @@ -143,7 +167,71 @@ impl Secret { } } +pub fn seed_phrase_from_seed(seed: Option<&str>) -> Result { + Ok(if let Some(seed) = seed.map(str::as_bytes) { + sep5::SeedPhrase::from_entropy(seed)? + } else { + sep5::SeedPhrase::random(sep5::MnemonicType::Words24)? + }) +} + +pub fn test_seed_phrase() -> Result { + Ok("0000000000000000".parse()?) +} + fn read_password() -> Result { std::io::stdout().flush().map_err(|_| Error::PasswordRead)?; rpassword::read_password().map_err(|_| Error::PasswordRead) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ"; + const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH"; + const TEST_SEED_PHRASE: &str = + "depth decade power loud smile spatial sign movie judge february rate broccoli"; + + #[test] + fn test_from_str_for_secret_key() { + let secret = Secret::from_str(TEST_SECRET_KEY).unwrap(); + let public_key = secret.public_key(None).unwrap(); + let private_key = secret.private_key(None).unwrap(); + + assert!(matches!(secret, Secret::SecretKey { .. })); + assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY); + assert_eq!(private_key.to_string(), TEST_SECRET_KEY); + } + + #[test] + fn test_secret_from_seed_phrase() { + let secret = Secret::from_str(TEST_SEED_PHRASE).unwrap(); + let public_key = secret.public_key(None).unwrap(); + let private_key = secret.private_key(None).unwrap(); + + assert!(matches!(secret, Secret::SeedPhrase { .. })); + assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY); + assert_eq!(private_key.to_string(), TEST_SECRET_KEY); + } + + #[test] + fn test_secret_from_secure_store() { + //todo: add assertion for getting public key - will need to mock the keychain and add the keypair to the keychain + let secret = Secret::from_str("secure_store:org.stellar.cli-alice").unwrap(); + assert!(matches!(secret, Secret::SecureStore { .. })); + + let private_key_result = secret.private_key(None); + assert!(private_key_result.is_err()); + assert!(matches!( + private_key_result.unwrap_err(), + Error::SecureStoreDoesNotRevealSecretKey + )); + } + + #[test] + fn test_secret_from_invalid_string() { + let secret = Secret::from_str("invalid"); + assert!(secret.is_err()); + } +} diff --git a/cmd/soroban-cli/src/key.rs b/cmd/soroban-cli/src/key.rs index b704541c4..c3fd7ed89 100644 --- a/cmd/soroban-cli/src/key.rs +++ b/cmd/soroban-cli/src/key.rs @@ -34,7 +34,7 @@ pub struct Args { required_unless_present = "wasm", required_unless_present = "wasm_hash" )] - pub contract_id: Option, + pub contract_id: Option, /// Storage key (symbols only) #[arg(long = "key", conflicts_with = "key_xdr")] pub key: Option>, diff --git a/cmd/soroban-cli/src/signer.rs b/cmd/soroban-cli/src/signer.rs index 5bf22499c..ebd650d40 100644 --- a/cmd/soroban-cli/src/signer.rs +++ b/cmd/soroban-cli/src/signer.rs @@ -1,4 +1,5 @@ use ed25519_dalek::ed25519::signature::Signer as _; +use keyring::StellarEntry; use sha2::{Digest, Sha256}; use crate::xdr::{ @@ -11,6 +12,8 @@ use crate::xdr::{ use crate::{config::network::Network, print::Print, utils::transaction_hash}; +pub mod keyring; + #[derive(thiserror::Error, Debug)] pub enum Error { #[error("Contract addresses are not supported to sign auth entries {address}")] @@ -33,6 +36,8 @@ pub enum Error { Open(#[from] std::io::Error), #[error("Returning a signature from Lab is not yet supported; Transaction can be found and submitted in lab")] ReturningSignatureFromLab, + #[error(transparent)] + Keyring(#[from] keyring::Error), } fn requires_auth(txn: &Transaction) -> Option { @@ -207,6 +212,7 @@ pub struct Signer { pub enum SignerKind { Local(LocalKey), Lab, + SecureStore(SecureStoreEntry), } impl Signer { @@ -235,6 +241,7 @@ impl Signer { let decorated_signature = match &self.kind { SignerKind::Local(key) => key.sign_tx_hash(tx_hash)?, SignerKind::Lab => Lab::sign_tx_env(tx_env, network, &self.print)?, + SignerKind::SecureStore(entry) => entry.sign_tx_hash(tx_hash)?, }; let mut sigs = signatures.clone().into_vec(); sigs.push(decorated_signature); @@ -284,3 +291,18 @@ impl Lab { Err(Error::ReturningSignatureFromLab) } } + +pub struct SecureStoreEntry { + pub name: String, + pub hd_path: Option, +} + +impl SecureStoreEntry { + pub fn sign_tx_hash(&self, tx_hash: [u8; 32]) -> Result { + let entry = StellarEntry::new(&self.name)?; + let hint = SignatureHint(entry.get_public_key(self.hd_path)?.0[28..].try_into()?); + let signed_tx_hash = entry.sign_data(&tx_hash, self.hd_path)?; + let signature = Signature(signed_tx_hash.clone().try_into()?); + Ok(DecoratedSignature { hint, signature }) + } +} diff --git a/cmd/soroban-cli/src/signer/keyring.rs b/cmd/soroban-cli/src/signer/keyring.rs new file mode 100644 index 000000000..0e6c49137 --- /dev/null +++ b/cmd/soroban-cli/src/signer/keyring.rs @@ -0,0 +1,147 @@ +use ed25519_dalek::Signer; +use keyring::Entry; +use sep5::seed_phrase::SeedPhrase; +use zeroize::Zeroize; + +pub(crate) const SECURE_STORE_ENTRY_PREFIX: &str = "secure_store:"; +pub(crate) const SECURE_STORE_ENTRY_SERVICE: &str = "org.stellar.cli"; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Keyring(#[from] keyring::Error), + #[error(transparent)] + Sep5(#[from] sep5::error::Error), +} + +pub struct StellarEntry { + keyring: Entry, +} + +impl StellarEntry { + pub fn new(name: &str) -> Result { + Ok(StellarEntry { + keyring: Entry::new(name, &whoami::username())?, + }) + } + + pub fn set_seed_phrase(&self, seed_phrase: SeedPhrase) -> Result<(), Error> { + let mut data = seed_phrase.seed_phrase.into_phrase(); + self.keyring.set_password(&data)?; + data.zeroize(); + Ok(()) + } + + fn get_seed_phrase(&self) -> Result { + Ok(self.keyring.get_password()?.parse()?) + } + + fn use_key( + &self, + f: impl FnOnce(ed25519_dalek::SigningKey) -> Result, + hd_path: Option, + ) -> Result { + // The underlying Mnemonic type is zeroized when dropped + let mut key_bytes: [u8; 32] = { + self.get_seed_phrase()? + .from_path_index(hd_path.unwrap_or_default(), None)? + .private() + .0 + }; + let result = { + // Use this scope to ensure the keypair is zeroized when dropped + let keypair = ed25519_dalek::SigningKey::from_bytes(&key_bytes); + f(keypair)? + }; + key_bytes.zeroize(); + Ok(result) + } + + pub fn get_public_key( + &self, + hd_path: Option, + ) -> Result { + self.use_key( + |keypair| { + Ok(stellar_strkey::ed25519::PublicKey( + *keypair.verifying_key().as_bytes(), + )) + }, + hd_path, + ) + } + + pub fn sign_data(&self, data: &[u8], hd_path: Option) -> Result, Error> { + self.use_key( + |keypair| { + let signature = keypair.sign(data); + Ok(signature.to_bytes().to_vec()) + }, + hd_path, + ) + } +} + +#[cfg(test)] +mod test { + use super::*; + use keyring::{mock, set_default_credential_builder}; + + #[test] + fn test_get_password() { + set_default_credential_builder(mock::default_credential_builder()); + + let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap(); + let seed_phrase_clone = seed_phrase.clone(); + + let entry = StellarEntry::new("test").unwrap(); + + // set the seed phrase + let set_seed_phrase_result = entry.set_seed_phrase(seed_phrase); + assert!(set_seed_phrase_result.is_ok()); + + // get_seed_phrase should return the same seed phrase we set + let get_seed_phrase_result = entry.get_seed_phrase(); + assert!(get_seed_phrase_result.is_ok()); + assert_eq!( + seed_phrase_clone.phrase(), + get_seed_phrase_result.unwrap().phrase() + ); + } + + #[test] + fn test_get_public_key() { + set_default_credential_builder(mock::default_credential_builder()); + + let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap(); + let public_key = seed_phrase.from_path_index(0, None).unwrap().public().0; + + let entry = StellarEntry::new("test").unwrap(); + + // set the seed_phrase + let set_seed_phrase_result = entry.set_seed_phrase(seed_phrase); + assert!(set_seed_phrase_result.is_ok()); + + // confirm that we can get the public key from the entry and that it matches the one we set + let get_public_key_result = entry.get_public_key(None); + assert!(get_public_key_result.is_ok()); + assert_eq!(public_key, get_public_key_result.unwrap().0); + } + + #[test] + fn test_sign_data() { + set_default_credential_builder(mock::default_credential_builder()); + + //create a seed phrase + let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap(); + + // create a keyring entry and set the seed_phrase + let entry = StellarEntry::new("test").unwrap(); + entry.set_seed_phrase(seed_phrase).unwrap(); + + let tx_xdr = r"AAAAAgAAAADh6eOnZEq1xQgKioffuH7/8D8x8+OdGFEkiYC6QKMWzQAAAGQAAACuAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAQAAAAAAAAAAAAAAAOHp46dkSrXFCAqKh9+4fv/wPzHz450YUSSJgLpAoxbNoFT1s8jZPCv9IJ2DsqGTA8pOtavv58JF53aDycpRPcEAAAAA+N2m5zc3EfWUmLvigYPOHKXhSy8OrWfVibc6y6PrQoYAAAAAAAAAAAAAAAA"; + + let sign_tx_env_result = entry.sign_data(tx_xdr.as_bytes(), None); + assert!(sign_tx_env_result.is_ok()); + } +}