diff --git a/Cargo.lock b/Cargo.lock index 0d717e1be..766398452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6179,6 +6179,13 @@ dependencies = [ "test-case-core", ] +[[package]] +name = "test_auth" +version = "25.2.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "test_constructor" version = "26.0.0" diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/Cargo.toml b/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/Cargo.toml new file mode 100644 index 000000000..aabcbab5d --- /dev/null +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "test_auth" +version = "25.2.0" +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"]} diff --git a/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/src/lib.rs b/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/src/lib.rs new file mode 100644 index 000000000..4d4f46b64 --- /dev/null +++ b/cmd/crates/soroban-test/tests/fixtures/test-wasms/auth/src/lib.rs @@ -0,0 +1,386 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, vec, Address, Env, IntoVal, Symbol}; + +#[contract] +pub struct AuthContract; + +#[contractimpl] +impl AuthContract { + /// Constructor with auth + pub fn __constructor(_env: Env, addr: Address) { + addr.require_auth(); + } + + /// require_auth on addr + /// + /// Used by other functions to emulate different nested auth options + pub fn do_auth(_e: Env, addr: Address, val: Symbol) -> Symbol { + addr.require_auth(); + val + } + + /// require_auth on `addr` + /// -> `subcall` does require_auth on `addr` + /// + /// Used by other functions to emulate different nested auth options + pub fn auth_sub_auth(e: Env, addr: Address, val: Symbol, subcall: Address) -> Symbol { + addr.require_auth(); + + let fn_symbol = Symbol::new(&e, "do_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![&e, addr.into_val(&e), val.into_val(&e)], + ) + } + + /// require_auth on `addr` + /// -> `subcall` does require_auth on `addr` + /// -> `subcall2` does require_auth on `addr` + pub fn auth_sub_nested_auth( + e: Env, + addr: Address, + val: Symbol, + subcall: Address, + subcall2: Address, + ) -> Symbol { + addr.require_auth(); + + let fn_symbol = Symbol::new(&e, "auth_sub_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![ + &e, + addr.into_val(&e), + val.into_val(&e), + subcall2.into_val(&e), + ], + ) + } + + /// require_auth_for_args(val) on `addr` + /// -> `subcall` does require_auth on `addr` + pub fn partial_auth_sub_auth(e: Env, addr: Address, val: Symbol, subcall: Address) -> Symbol { + addr.require_auth_for_args(vec![&e, addr.into_val(&e), val.into_val(&e)]); + + let fn_symbol = Symbol::new(&e, "do_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![&e, addr.into_val(&e), val.into_val(&e)], + ) + } + + /// require_auth_for_args(1i128, 2i128) on `addr` + /// -> `subcall` does require_auth on `addr` + pub fn diff_auth_sub_auth(e: Env, addr: Address, val: Symbol, subcall: Address) -> Symbol { + addr.require_auth_for_args(vec![&e, 1i128.into_val(&e), 2i128.into_val(&e)]); + + let fn_symbol = Symbol::new(&e, "do_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![&e, addr.into_val(&e), val.into_val(&e)], + ) + } + + /// no auth + /// -> `subcall` does require_auth on `addr` + pub fn no_auth_sub_auth(e: Env, addr: Address, val: Symbol, subcall: Address) -> Symbol { + let fn_symbol = Symbol::new(&e, "do_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![&e, addr.into_val(&e), val.into_val(&e)], + ) + } + + /// no auth + /// -> `subcall` does require_auth on `addr` + /// -> `subcall2` does require_auth on `addr` + pub fn no_auth_sub_nested_auth( + e: Env, + addr: Address, + val: Symbol, + subcall: Address, + subcall2: Address, + ) -> Symbol { + let fn_symbol = Symbol::new(&e, "auth_sub_auth"); + e.invoke_contract::( + &subcall, + &fn_symbol, + vec![ + &e, + addr.into_val(&e), + val.into_val(&e), + subcall2.into_val(&e), + ], + ) + } +} + +#[cfg(test)] +mod test { + extern crate std; + + use soroban_sdk::{ + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + Address, Env, IntoVal, Symbol, + }; + + use crate::{AuthContract, AuthContractClient}; + + #[test] + fn test_do_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id = env.register(AuthContract, (user.clone(),)); + let client = AuthContractClient::new(&env, &contract_id); + + let res = client.do_auth(&user, &val); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_auth_sub_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.auth_sub_auth(&user, &val, &contract_id_2); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_1.clone(), + Symbol::new(&env, "auth_sub_auth"), + (&user, &val, &contract_id_2).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_auth_sub_nested_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + let contract_id_3 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.auth_sub_nested_auth(&user, &val, &contract_id_2, &contract_id_3); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_1.clone(), + Symbol::new(&env, "auth_sub_nested_auth"), + (&user, &val, &contract_id_2, &contract_id_3).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "auth_sub_auth"), + (&user, &val, &contract_id_3).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_3.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }], + }], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_partial_auth_sub_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.partial_auth_sub_auth(&user, &val, &contract_id_2); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_1.clone(), + Symbol::new(&env, "partial_auth_sub_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_diff_auth_sub_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.diff_auth_sub_auth(&user, &val, &contract_id_2); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_1.clone(), + Symbol::new(&env, "diff_auth_sub_auth"), + (&1i128, &2i128).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_no_auth_sub_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.no_auth_sub_auth(&user, &val, &contract_id_2); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }, + )] + ); + assert_eq!(res, val); + } + + #[test] + fn test_no_auth_sub_nested_auth_creates_expected_auth() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let user = Address::generate(&env); + let val = Symbol::new(&env, "test_auth"); + + let contract_id_1 = env.register(AuthContract, (user.clone(),)); + let client_1 = AuthContractClient::new(&env, &contract_id_1); + let contract_id_2 = env.register(AuthContract, (user.clone(),)); + let contract_id_3 = env.register(AuthContract, (user.clone(),)); + + let res = client_1.no_auth_sub_nested_auth(&user, &val, &contract_id_2, &contract_id_3); + assert_eq!( + env.auths(), + std::vec![( + user.clone(), + AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_2.clone(), + Symbol::new(&env, "auth_sub_auth"), + (&user, &val, &contract_id_3).into_val(&env), + )), + sub_invocations: std::vec![AuthorizedInvocation { + function: AuthorizedFunction::Contract(( + contract_id_3.clone(), + Symbol::new(&env, "do_auth"), + (&user, &val).into_val(&env), + )), + sub_invocations: std::vec![], + }], + }, + )] + ); + assert_eq!(res, val); + } +} diff --git a/cmd/crates/soroban-test/tests/it/integration.rs b/cmd/crates/soroban-test/tests/it/integration.rs index 7f749a767..9fa5a46ad 100644 --- a/cmd/crates/soroban-test/tests/it/integration.rs +++ b/cmd/crates/soroban-test/tests/it/integration.rs @@ -1,3 +1,4 @@ +mod auth; mod auto_build; mod bindings; mod constructor; diff --git a/cmd/crates/soroban-test/tests/it/integration/auth.rs b/cmd/crates/soroban-test/tests/it/integration/auth.rs new file mode 100644 index 000000000..e36cc9bbb --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/integration/auth.rs @@ -0,0 +1,169 @@ +use assert_cmd::Command; +use soroban_test::{AssertExt, TestEnv}; + +use super::util::{extend_contract, new_account, AUTH}; + +fn constructor_cmd(sandbox: &TestEnv, addr: &str) -> Command { + let mut cmd = sandbox.new_assert_cmd("contract"); + cmd.arg("deploy") + .arg("--source=test") + .arg("--wasm") + .arg(AUTH.path()); + cmd.arg("--").arg("--addr").arg(addr); + cmd +} + +/// Helper to deploy two instances of the auth contract and extend them. +/// Returns (contract_id_1, contract_id_2). +async fn deploy_auth_contracts(sandbox: &TestEnv) -> (String, String) { + let id1 = constructor_cmd(sandbox, "test") + .assert() + .success() + .stdout_as_str(); + extend_contract(sandbox, &id1).await; + + let id2 = constructor_cmd(sandbox, "test") + .assert() + .success() + .stdout_as_str(); + extend_contract(sandbox, &id2).await; + + (id1, id2) +} + +#[tokio::test] +async fn standard_auth_with_separate_signer() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id, _) = deploy_auth_contracts(sandbox).await; + + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id) + .arg("--") + .arg("do-auth") + .arg("--addr=signer") + .arg("--val=hello") + .assert() + .success() + .stdout("\"hello\"\n"); +} + +#[tokio::test] +async fn root_auth_with_authorized_subcall() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id1, id2) = deploy_auth_contracts(sandbox).await; + + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--") + .arg("auth-sub-auth") + .arg("--addr=signer") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .success() + .stdout("\"hello\"\n"); +} + +#[tokio::test] +async fn non_root_auth_with_authorized_subcall() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id1, id2) = deploy_auth_contracts(sandbox).await; + + // with non-source signer - expect failure + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--") + .arg("no-auth-sub-auth") + .arg("--addr=signer") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .failure() + .stderr(predicates::str::contains("Auth, InvalidAction")); + + // with source signer - expect failure + // TODO: this should pass once CLI supports non-root auth + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--") + .arg("no-auth-sub-auth") + .arg("--addr=test") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .failure() + .stderr(predicates::str::contains("Auth, InvalidAction")); +} + +#[tokio::test] +async fn partial_auth_with_authorized_subcall() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + let (id1, id2) = deploy_auth_contracts(sandbox).await; + + // with non-source signer - expect failure + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--") + .arg("partial_auth_sub_auth") + .arg("--addr=signer") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .failure() + .stderr(predicates::str::contains("Signing authorization entries that could be submitted outside the context of the transaction is not supported in the CLI")); + + // with source signer - expect success + sandbox + .new_assert_cmd("contract") + .arg("invoke") + .arg("--source=test") + .arg("--id") + .arg(&id1) + .arg("--") + .arg("partial_auth_sub_auth") + .arg("--addr=test") + .arg("--val=hello") + .arg(&format!("--subcall={id2}")) + .assert() + .success() + .stdout("\"hello\"\n"); +} + +#[tokio::test] +async fn constructor_auth_with_non_source_signer() { + let sandbox = &TestEnv::new(); + new_account(sandbox, "signer"); + + constructor_cmd(sandbox, "signer") + .assert() + .failure() + .stderr(predicates::str::contains("Auth, InvalidAction")); +} diff --git a/cmd/crates/soroban-test/tests/it/integration/util.rs b/cmd/crates/soroban-test/tests/it/integration/util.rs index 21eb71874..826c985e4 100644 --- a/cmd/crates/soroban-test/tests/it/integration/util.rs +++ b/cmd/crates/soroban-test/tests/it/integration/util.rs @@ -5,6 +5,7 @@ use soroban_cli::{ use soroban_test::{AssertExt, TestEnv, Wasm}; use std::fmt::Display; +pub const AUTH: &Wasm = &Wasm::Custom("test-wasms", "test_auth"); pub const HELLO_WORLD: &Wasm = &Wasm::Custom("test-wasms", "test_hello_world"); pub const CONSTRUCTOR: &Wasm = &Wasm::Custom("test-wasms", "test_constructor"); pub const CUSTOM_TYPES: &Wasm = &Wasm::Custom("test-wasms", "test_custom_types"); diff --git a/cmd/soroban-cli/src/commands/contract/extend.rs b/cmd/soroban-cli/src/commands/contract/extend.rs index 3ae6aaad4..7f58469be 100644 --- a/cmd/soroban-cli/src/commands/contract/extend.rs +++ b/cmd/soroban-cli/src/commands/contract/extend.rs @@ -196,6 +196,9 @@ impl Cmd { tracing::trace!(?network); let keys = self.key.parse_keys(&config.locator, &network)?; let client = network.rpc_client()?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; let source_account = config.source_account().await?; let extend_to = self.ledgers_to_extend(&client).await?; diff --git a/cmd/soroban-cli/src/commands/contract/restore.rs b/cmd/soroban-cli/src/commands/contract/restore.rs index 54d72abf1..161aa9941 100644 --- a/cmd/soroban-cli/src/commands/contract/restore.rs +++ b/cmd/soroban-cli/src/commands/contract/restore.rs @@ -165,6 +165,9 @@ impl Cmd { tracing::trace!(?network); let entry_keys = self.key.parse_keys(&config.locator, &network)?; let client = network.rpc_client()?; + client + .verify_network_passphrase(Some(&network.network_passphrase)) + .await?; let source_account = config.source_account().await?; // Get the account sequence number diff --git a/cmd/soroban-cli/src/log/auth.rs b/cmd/soroban-cli/src/log/auth.rs index 4a6b4bea8..315d9a5d8 100644 --- a/cmd/soroban-cli/src/log/auth.rs +++ b/cmd/soroban-cli/src/log/auth.rs @@ -1,7 +1,87 @@ -use crate::xdr::{SorobanAuthorizationEntry, VecM}; +use std::fmt::Write; -pub fn auth(auth: &[VecM]) { - if !auth.is_empty() { - tracing::debug!("{auth:#?}"); +use crate::xdr::{ + AccountId, InvokeContractArgs, PublicKey, ScAddress, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, SorobanCredentials, Uint256, +}; + +/// Format a single auth entry for display. +pub fn format_auth_entry(entry: &SorobanAuthorizationEntry) -> String { + let mut result = String::from(" Auth Entry:\n"); + + match &entry.credentials { + SorobanCredentials::Address(creds) => { + let _ = writeln!(result, " Signer: {}", format_address(&creds.address)); + } + SorobanCredentials::SourceAccount => { + result.push_str(" Signer: \n"); + } + } + + format_invocation(&entry.root_invocation, 2, "Invocation:", &mut result); + + result +} + +/// Recursively format a `SorobanAuthorizedInvocation` tree. `label` is the +/// header line printed for this node — `"Invocation:"` for the root and +/// `"Sub-invocation #N:"` for each child. +fn format_invocation( + invocation: &SorobanAuthorizedInvocation, + indent: usize, + label: &str, + result: &mut String, +) { + let prefix = " ".repeat(indent); + let _ = writeln!(result, "{prefix}{label}"); + + match &invocation.function { + SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address, + function_name, + args, + }) => { + let fn_name = std::str::from_utf8(function_name.as_ref()).unwrap_or(""); + let _ = writeln!( + result, + "{prefix} Contract: {}", + format_address(contract_address) + ); + let _ = writeln!(result, "{prefix} Fn: {fn_name}"); + if !args.is_empty() { + let _ = writeln!(result, "{prefix} Args:"); + for arg in args.iter() { + let _ = writeln!( + result, + "{prefix} {}", + soroban_spec_tools::to_string(arg) + .unwrap_or(String::from("")) + ); + } + } + } + SorobanAuthorizedFunction::CreateContractHostFn(_) + | SorobanAuthorizedFunction::CreateContractV2HostFn(_) => { + let _ = writeln!(result, "{prefix} CreateContract"); + } + } + + for (i, sub) in invocation.sub_invocations.iter().enumerate() { + let sub_label = format!("Sub-invocation #{i}:"); + format_invocation(sub, indent + 1, &sub_label, result); + } +} + +/// Format an ScAddress as a strkey string for display. +fn format_address(address: &ScAddress) -> String { + match address { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes)))) => { + stellar_strkey::Strkey::PublicKeyEd25519(stellar_strkey::ed25519::PublicKey(*bytes)) + .to_string() + } + ScAddress::Contract(stellar_xdr::curr::ContractId(stellar_xdr::curr::Hash(bytes))) => { + stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*bytes)).to_string() + } + _ => format!("{address:?}"), } } diff --git a/cmd/soroban-cli/src/signer/mod.rs b/cmd/soroban-cli/src/signer/mod.rs index d27c7e173..529edc935 100644 --- a/cmd/soroban-cli/src/signer/mod.rs +++ b/cmd/soroban-cli/src/signer/mod.rs @@ -1,11 +1,12 @@ use crate::{ + log::format_auth_entry, utils::fee_bump_transaction_hash, xdr::{ self, AccountId, DecoratedSignature, FeeBumpTransactionEnvelope, Hash, HashIdPreimage, - HashIdPreimageSorobanAuthorization, Limits, Operation, OperationBody, PublicKey, ScAddress, - ScMap, ScSymbol, ScVal, Signature, SignatureHint, SorobanAddressCredentials, - SorobanAuthorizationEntry, SorobanCredentials, Transaction, TransactionEnvelope, - TransactionV1Envelope, Uint256, VecM, WriteXdr, + HashIdPreimageSorobanAuthorization, Limits, MuxedAccount, Operation, OperationBody, + PublicKey, ScAddress, ScMap, ScSymbol, ScVal, Signature, SignatureHint, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, Transaction, + TransactionEnvelope, TransactionV1Envelope, Uint256, VecM, WriteXdr, }, }; use ed25519_dalek::{ed25519::signature::Signer as _, Signature as Ed25519Signature}; @@ -14,6 +15,7 @@ use sha2::{Digest, Sha256}; use crate::{config::network::Network, print::Print, utils::transaction_hash}; pub mod ledger; +pub mod validation; #[cfg(feature = "additional-libs")] mod keyring; @@ -29,8 +31,13 @@ pub enum Error { MissingSignerForAddress { address: String }, #[error(transparent)] TryFromSlice(#[from] std::array::TryFromSliceError), - #[error("User cancelled signing, perhaps need to add -y")] - UserCancelledSigning, + #[error("Signing authorization entries that could be submitted outside the context of the transaction is not supported in the CLI:\n{auth_entry_str}")] + NotStrictAuthEntry { auth_entry_str: String }, + #[error("Invalid Soroban authorization entry - {reason}:\n{auth_entry_str}")] + InvalidAuthEntry { + reason: String, + auth_entry_str: String, + }, #[error(transparent)] Xdr(#[from] xdr::Error), #[error("Transaction envelope type not supported")] @@ -72,6 +79,7 @@ pub fn sign_soroban_authorizations( }; let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into()); + let source_bytes = muxed_account_bytes(&raw.source_account); let mut auths_modified = false; let mut signed_auths = Vec::with_capacity(body.auth.len()); @@ -88,9 +96,25 @@ pub fn sign_soroban_authorizations( }; let SorobanAddressCredentials { ref address, .. } = credentials; + // Before we attempt to sign, validate the auth entry is strict + match validation::classify_auth_invocation(&body.host_function, &auth.root_invocation) { + validation::AuthStyle::Strict => {} + validation::AuthStyle::NonStrict => { + return Err(Error::NotStrictAuthEntry { + auth_entry_str: format_auth_entry(&auth), + }); + } + validation::AuthStyle::Invalid => { + return Err(Error::InvalidAuthEntry { + reason: "authorization entry is not expected for the transaction".to_string(), + auth_entry_str: format_auth_entry(&auth), + }); + } + } + // See if we have a signer for this authorizationEntry // If not, then we Error - let needle: &[u8; 32] = match address { + let auth_address_bytes: &[u8; 32] = match address { ScAddress::MuxedAccount(_) => todo!("muxed accounts are not supported"), ScAddress::ClaimableBalance(_) => todo!("claimable balance not supported"), ScAddress::LiquidityPool(_) => todo!("liquidity pool not supported"), @@ -105,9 +129,17 @@ pub fn sign_soroban_authorizations( } }; + // Auth entries should not request a signature from the tx source account via the `Address` credential type + if auth_address_bytes == source_bytes { + return Err(Error::InvalidAuthEntry { + reason: "transaction source account is used as credentials".to_string(), + auth_entry_str: format_auth_entry(&auth), + }); + } + let mut signer: Option<&Signer> = None; for s in signers { - if needle == &s.get_public_key()?.0 { + if auth_address_bytes == &s.get_public_key()?.0 { signer = Some(s); } } @@ -126,7 +158,7 @@ pub fn sign_soroban_authorizations( None => { return Err(Error::MissingSignerForAddress { address: stellar_strkey::Strkey::PublicKeyEd25519( - stellar_strkey::ed25519::PublicKey(*needle), + stellar_strkey::ed25519::PublicKey(*auth_address_bytes), ) .to_string(), }); @@ -377,3 +409,230 @@ impl SecureStoreEntry { Ok(sig) } } + +/// Extract the Ed25519 public key bytes from a MuxedAccount +fn muxed_account_bytes(source: &MuxedAccount) -> &[u8; 32] { + match source { + MuxedAccount::Ed25519(Uint256(bytes)) => bytes, + MuxedAccount::MuxedEd25519(muxed) => &muxed.ed25519.0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::xdr::{ + BytesM, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Memo, Preconditions, + SequenceNumber, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, TransactionExt, + }; + + const NETWORK: &str = "Test SDF Network ; September 2015"; + const EXPIRATION_LEDGER: u32 = 100; + + fn local_signer(seed: [u8; 32]) -> Signer { + Signer { + kind: SignerKind::Local(LocalKey { + key: ed25519_dalek::SigningKey::from_bytes(&seed), + }), + print: Print::new(true), + } + } + + fn signer_pubkey(signer: &Signer) -> [u8; 32] { + signer.get_public_key().unwrap().0 + } + + fn ed25519_address(bytes: [u8; 32]) -> ScAddress { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes)))) + } + + fn invoke_args(contract: [u8; 32], fn_name: &str) -> InvokeContractArgs { + InvokeContractArgs { + contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(contract))), + function_name: ScSymbol(fn_name.try_into().unwrap()), + args: VecM::default(), + } + } + + fn invocation(contract: [u8; 32], fn_name: &str) -> SorobanAuthorizedInvocation { + SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(invoke_args(contract, fn_name)), + sub_invocations: VecM::default(), + } + } + + fn address_auth( + address: ScAddress, + invocation: SorobanAuthorizedInvocation, + ) -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address, + nonce: 0, + signature_expiration_ledger: 0, + signature: ScVal::Void, + }), + root_invocation: invocation, + } + } + + fn build_tx( + source: MuxedAccount, + host_function: HostFunction, + auth: Vec, + ) -> Transaction { + Transaction { + source_account: source, + fee: 100, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![Operation { + source_account: None, + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { + host_function, + auth: auth.try_into().unwrap(), + }), + }] + .try_into() + .unwrap(), + ext: TransactionExt::V0, + } + } + + /// Pull the embedded public_key bytes out of a signed Address-cred entry. + fn extract_signed_pubkey(creds: &SorobanAddressCredentials) -> [u8; 32] { + let ScVal::Vec(Some(outer)) = &creds.signature else { + panic!("expected ScVal::Vec signature"); + }; + let Some(ScVal::Map(Some(map))) = outer.first() else { + panic!("expected ScVal::Map inside signature vec"); + }; + map.iter() + .find_map(|e| match (&e.key, &e.val) { + (ScVal::Symbol(s), ScVal::Bytes(b)) if s.0.as_slice() == b"public_key" => { + Some(b.as_slice().try_into().unwrap()) + } + _ => None, + }) + .expect("public_key entry") + } + + #[test] + fn test_signs_address_auth_entry_with_matching_signer() { + let signer = local_signer([1u8; 32]); + let signer_unused = local_signer([2u8; 32]); + let signer_pk = signer_pubkey(&signer); + let source = MuxedAccount::Ed25519(Uint256([9u8; 32])); + let contract = [42u8; 32]; + + let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello")); + let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello")); + let tx = build_tx(source, host_fn, vec![entry]); + + let signed_auth_tx = + sign_soroban_authorizations(&tx, &[signer_unused, signer], EXPIRATION_LEDGER, NETWORK) + .unwrap() + .expect("signing modifies the transaction"); + + let OperationBody::InvokeHostFunction(body) = &signed_auth_tx.operations[0].body else { + panic!("expected InvokeHostFunction"); + }; + let SorobanCredentials::Address(creds) = &body.auth[0].credentials else { + panic!("expected Address credentials"); + }; + assert!( + !matches!(creds.signature, ScVal::Void), + "signature should be filled in" + ); + assert_eq!(creds.signature_expiration_ledger, EXPIRATION_LEDGER); + assert_eq!( + extract_signed_pubkey(creds), + signer_pk, + "embedded public_key should match the signer" + ); + } + + #[test] + fn test_non_strict_auth_returns_error() { + let signer = local_signer([1u8; 32]); + let signer_pk = signer_pubkey(&signer); + let source = MuxedAccount::Ed25519(Uint256([9u8; 32])); + let contract = [42u8; 32]; + let other_contract = [99u8; 32]; + + let entry = address_auth( + ed25519_address(signer_pk), + invocation(other_contract, "hello"), + ); + let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello")); + let tx = build_tx(source, host_fn, vec![entry]); + + let result = sign_soroban_authorizations(&tx, &[signer], EXPIRATION_LEDGER, NETWORK); + assert!(matches!(result, Err(Error::NotStrictAuthEntry { .. }))); + } + + #[test] + fn test_multiple_entries_with_non_strict_returns_error() { + let signer = local_signer([1u8; 32]); + let signer_pk = signer_pubkey(&signer); + let source = MuxedAccount::Ed25519(Uint256([9u8; 32])); + let contract = [42u8; 32]; + let other_contract = [99u8; 32]; + + let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello")); + let entry_non_strict = address_auth( + ed25519_address(signer_pk), + invocation(other_contract, "hello"), + ); + let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello")); + let tx = build_tx(source, host_fn, vec![entry, entry_non_strict]); + + let result = sign_soroban_authorizations(&tx, &[signer], EXPIRATION_LEDGER, NETWORK); + assert!(matches!(result, Err(Error::NotStrictAuthEntry { .. }))); + } + + #[test] + fn test_upload_wasm_with_auth_returns_invalid() { + let signer = local_signer([1u8; 32]); + let signer_pk = signer_pubkey(&signer); + let source = MuxedAccount::Ed25519(Uint256([9u8; 32])); + let wasm: BytesM = [0u8; 32].try_into().unwrap(); + + let entry = address_auth(ed25519_address(signer_pk), invocation([42u8; 32], "hello")); + let host_fn = HostFunction::UploadContractWasm(wasm); + let tx = build_tx(source, host_fn, vec![entry]); + + let result = sign_soroban_authorizations(&tx, &[signer], EXPIRATION_LEDGER, NETWORK); + assert!(matches!(result, Err(Error::InvalidAuthEntry { .. }))); + } + + #[test] + fn test_source_account_as_address_returns_invalid() { + let signer = local_signer([1u8; 32]); + let signer_pk = signer_pubkey(&signer); + let source = MuxedAccount::Ed25519(Uint256(signer_pk)); + let contract = [42u8; 32]; + + let entry = address_auth(ed25519_address(signer_pk), invocation(contract, "hello")); + let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello")); + let tx = build_tx(source, host_fn, vec![entry]); + + let result = sign_soroban_authorizations(&tx, &[signer], EXPIRATION_LEDGER, NETWORK); + assert!(matches!(result, Err(Error::InvalidAuthEntry { .. }))); + } + + #[test] + fn test_missing_signer_returns_error() { + let source = MuxedAccount::Ed25519(Uint256([9u8; 32])); + let contract = [42u8; 32]; + let unknown = [77u8; 32]; + + let entry = address_auth(ed25519_address(unknown), invocation(contract, "hello")); + let host_fn = HostFunction::InvokeContract(invoke_args(contract, "hello")); + let tx = build_tx(source, host_fn, vec![entry]); + + let result = sign_soroban_authorizations(&tx, &[], EXPIRATION_LEDGER, NETWORK); + assert!(matches!(result, Err(Error::MissingSignerForAddress { .. }))); + } +} diff --git a/cmd/soroban-cli/src/signer/validation.rs b/cmd/soroban-cli/src/signer/validation.rs new file mode 100644 index 000000000..8cdc17868 --- /dev/null +++ b/cmd/soroban-cli/src/signer/validation.rs @@ -0,0 +1,222 @@ +use crate::xdr::{HostFunction, SorobanAuthorizedFunction, SorobanAuthorizedInvocation}; + +/// Classification of an `Address`-credential auth entry's relationship to the +/// transaction's host function. +/// +/// `SourceAccount` credential entries are out of scope here — they are signed +/// implicitly via the transaction envelope and never reach this classifier. +#[derive(Debug, PartialEq, Eq)] +pub enum AuthStyle { + /// `root_invocation` matches the host function exactly. Safe to sign: + /// the entry is bound to this transaction host function and cannot be replayed. + Strict, + /// `root_invocation` does not match the host function exactly. Signing this + /// could produce a portable authorization that could be submitted + /// outside the context of this transaction. + NonStrict, + /// `root_invocation` is not expected for the host function + Invalid, +} + +/// Classify an auth invocation against the transaction's host function. +/// +/// ### Arguments +/// * `source_host_fn`- The transaction's host function +/// * `auth_invocation` - The auth entry's root invocation +pub fn classify_auth_invocation( + source_host_fn: &HostFunction, + auth_invocation: &SorobanAuthorizedInvocation, +) -> AuthStyle { + // No auth entries are valid for `UploadContractWasm`. + if matches!(source_host_fn, HostFunction::UploadContractWasm(_)) { + return AuthStyle::Invalid; + } + + // Check if the auth entry's root invocation matches the host function exactly. + // This is different than just a `root_auth` check, as contracts that authorize with + // `require_auth_for_args` at the root are not considered strict auth. This tradeoff is + // made to ensure that even a tampered auth entry can be flagged as non-strict. + let is_strict = match (source_host_fn, &auth_invocation.function) { + (HostFunction::InvokeContract(op), SorobanAuthorizedFunction::ContractFn(args)) => { + args == op + } + ( + HostFunction::CreateContract(op), + SorobanAuthorizedFunction::CreateContractHostFn(args), + ) => args == op, + ( + HostFunction::CreateContractV2(op), + SorobanAuthorizedFunction::CreateContractV2HostFn(args), + ) => args == op, + _ => false, + }; + + if is_strict { + AuthStyle::Strict + } else { + AuthStyle::NonStrict + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::xdr::{ + AccountId, BytesM, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress, + CreateContractArgsV2, Hash, InvokeContractArgs, PublicKey, ScAddress, ScSymbol, ScVal, + Uint256, VecM, + }; + use stellar_strkey::ed25519; + + const SOURCE_ACCOUNT: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI"; + + fn source_bytes() -> [u8; 32] { + ed25519::PublicKey::from_string(SOURCE_ACCOUNT).unwrap().0 + } + + fn ed25519_address(bytes: [u8; 32]) -> ScAddress { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(bytes)))) + } + + fn host_fn_invoke(contract: [u8; 32], fn_name: &str, args: &[ScVal]) -> HostFunction { + HostFunction::InvokeContract(InvokeContractArgs { + contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(contract))), + function_name: ScSymbol(fn_name.try_into().unwrap()), + args: args.try_into().unwrap(), + }) + } + + fn host_fn_create(wasm_hash: [u8; 32], args: &[ScVal]) -> HostFunction { + HostFunction::CreateContractV2(CreateContractArgsV2 { + contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress { + address: ed25519_address(source_bytes()), + salt: Uint256([0u8; 32]), + }), + executable: ContractExecutable::Wasm(wasm_hash.into()), + constructor_args: args.try_into().unwrap(), + }) + } + + fn invocation_contract( + contract: [u8; 32], + fn_name: &str, + args: &[ScVal], + ) -> SorobanAuthorizedInvocation { + SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash( + contract, + ))), + function_name: ScSymbol(fn_name.try_into().unwrap()), + args: args.to_vec().try_into().unwrap(), + }), + sub_invocations: VecM::default(), + } + } + + fn invocation_create(wasm_hash: [u8; 32], args: &[ScVal]) -> SorobanAuthorizedInvocation { + SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::CreateContractV2HostFn(CreateContractArgsV2 { + contract_id_preimage: ContractIdPreimage::Address(ContractIdPreimageFromAddress { + address: ed25519_address(source_bytes()), + salt: Uint256([0u8; 32]), + }), + executable: ContractExecutable::Wasm(wasm_hash.into()), + constructor_args: args.try_into().unwrap(), + }), + sub_invocations: VecM::default(), + } + } + + #[test] + fn test_matching_root_invocation_is_strict() { + let contract = [1u8; 32]; + let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())]; + + let host_fn = host_fn_invoke(contract, "hello", args); + let invocation = invocation_contract(contract, "hello", args); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::Strict); + } + + #[test] + fn test_subinvocations_dont_affect_root_match() { + let contract = [1u8; 32]; + let other = [99u8; 32]; + let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())]; + + let host_fn = host_fn_invoke(contract, "hello", args); + let mut invocation = invocation_contract(contract, "hello", args); + invocation.sub_invocations = [invocation_contract(other, "other", &[])] + .try_into() + .unwrap(); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::Strict); + } + + #[test] + fn test_different_root_contract_is_non_strict() { + let contract = [1u8; 32]; + let other = [99u8; 32]; + + let host_fn = host_fn_invoke(contract, "hello", &[]); + let invocation = invocation_contract(other, "hello", &[]); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::NonStrict); + } + + #[test] + fn test_different_function_same_contract_is_non_strict() { + let contract = [1u8; 32]; + + let host_fn = host_fn_invoke(contract, "hello", &[]); + let invocation = invocation_contract(contract, "transfer", &[]); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::NonStrict); + } + + #[test] + fn test_different_args_is_non_strict() { + let contract = [1u8; 32]; + let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())]; + let wrong = &[ScVal::U32(43), ScVal::Symbol("hello".try_into().unwrap())]; + + let host_fn = host_fn_invoke(contract, "hello", args); + let invocation = invocation_contract(contract, "hello", wrong); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::NonStrict); + } + + #[test] + fn test_upload_wasm_with_auth_entry_is_invalid() { + let contract = [1u8; 32]; + let wasm_hash: BytesM = [42u8; 32].try_into().unwrap(); + + let host_fn = HostFunction::UploadContractWasm(wasm_hash); + let invocation = invocation_contract(contract, "hello", &[]); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::Invalid); + } + + #[test] + fn test_matching_create_contract_root_is_strict() { + let contract = [1u8; 32]; + let wasm_hash = [42u8; 32]; + let args = &[ScVal::U32(42), ScVal::Symbol("hello".try_into().unwrap())]; + + let host_fn = host_fn_create(wasm_hash, args); + let mut invocation = invocation_create(wasm_hash, args); + invocation.sub_invocations = [invocation_contract(contract, "__constructor", args)] + .try_into() + .unwrap(); + + let style = classify_auth_invocation(&host_fn, &invocation); + assert_eq!(style, AuthStyle::Strict); + } +} diff --git a/cmd/soroban-cli/src/tx.rs b/cmd/soroban-cli/src/tx.rs index 8aa3a9c37..4aebfb4a7 100644 --- a/cmd/soroban-cli/src/tx.rs +++ b/cmd/soroban-cli/src/tx.rs @@ -65,7 +65,8 @@ where data::write(sim_res.clone().into(), &network.rpc_uri()?)?; } - // Need to sign all auth entries + // Sign all auth entries. Each entry is validated against the transaction's + // host function inside `sign_soroban_authorizations` before being signed. if let Some(tx) = config .sign_soroban_authorizations(&txn, auth_signers) .await?