diff --git a/Cargo.lock b/Cargo.lock index 2172171c53..ad1f0c73b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3180,6 +3180,17 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libredox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607" +dependencies = [ + "bitflags 2.5.0", + "libc", + "redox_syscall 0.4.1", +] + [[package]] name = "libredox" version = "0.1.3" @@ -3403,6 +3414,12 @@ dependencies = [ "libc", ] +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + [[package]] name = "object" version = "0.32.2" @@ -3887,6 +3904,12 @@ dependencies = [ "bitflags 2.5.0", ] +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + [[package]] name = "redox_users" version = "0.4.5" @@ -3894,7 +3917,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" dependencies = [ "getrandom", - "libredox", + "libredox 0.1.3", "thiserror", ] @@ -4692,6 +4715,7 @@ dependencies = [ "tempfile", "termcolor", "termcolor_output", + "termion", "thiserror", "tokio", "toml 0.5.11", @@ -5279,6 +5303,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f34dde0bb841eb3762b42bdff8db11bbdbc0a3bd7b32012955f5ce1d081f86c1" +[[package]] +name = "termion" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af3fa9183465b9af93355585a6b1e24cc2ff25938b52aa6956f647b6490257a" +dependencies = [ + "libc", + "libredox 0.0.2", + "numtoa", + "redox_termios", +] + [[package]] name = "termtree" version = "0.4.1" diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index 0950aa94d0..0acc849bed 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -114,6 +114,7 @@ rust-embed = { version = "8.2.0", features = ["debug-embed"] } bollard = { workspace=true } futures-util = "0.3.30" home = "0.5.9" +termion = "4.0.0" # For hyper-tls [target.'cfg(unix)'.dependencies] openssl = { version = "=0.10.55", features = ["vendored"] } diff --git a/cmd/soroban-cli/src/commands/config/mod.rs b/cmd/soroban-cli/src/commands/config/mod.rs index e80aea73c2..d9f4d72941 100644 --- a/cmd/soroban-cli/src/commands/config/mod.rs +++ b/cmd/soroban-cli/src/commands/config/mod.rs @@ -3,7 +3,14 @@ use std::path::PathBuf; use clap::{arg, command}; use serde::{Deserialize, Serialize}; -use crate::Pwd; +use soroban_rpc::Client; +use stellar_strkey::Strkey; + +use crate::xdr::{MuxedAccount, SequenceNumber, Transaction, TransactionEnvelope, Uint256}; +use crate::{ + signer::{LocalKey, Stellar}, + Pwd, +}; use self::{network::Network, secret::Secret}; @@ -23,6 +30,8 @@ pub enum Error { Secret(#[from] secret::Error), #[error(transparent)] Config(#[from] locator::Error), + #[error(transparent)] + Rpc(#[from] soroban_rpc::Error), } #[derive(Debug, clap::Args, Clone, Default)] @@ -49,6 +58,32 @@ impl Args { Ok(key.key_pair(self.hd_path)?) } + + pub async fn sign_with_local_key( + &self, + tx: Transaction, + ) -> Result { + let signer = LocalKey::new(self.key_pair()?, false); + self.sign(&signer, tx).await + } + + pub async fn sign( + &self, + signer: &impl Stellar, + mut tx: Transaction, + ) -> Result { + let key = signer.get_public_key().await.unwrap(); + let account = Strkey::PublicKeyEd25519(key); + let network = self.get_network()?; + let client = Client::new(&network.rpc_url)?; + tx.seq_num = SequenceNumber(client.get_account(&account.to_string()).await?.seq_num.0 + 1); + tx.source_account = MuxedAccount::Ed25519(Uint256(key.0)); + Ok(signer + .sign_txn(tx, &network.network_passphrase) + .await + .unwrap()) + } + pub fn account(&self, account_str: &str) -> Result { if let Ok(secret) = self.locator.read_identity(account_str) { Ok(secret) diff --git a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs index 0b944c78ac..b901b0ce5e 100644 --- a/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs +++ b/cmd/soroban-cli/src/commands/contract/deploy/wasm.rs @@ -127,7 +127,7 @@ impl NetworkRunnable for Cmd { ) -> Result, Error> { let config = config.unwrap_or(&self.config); let wasm_hash = if let Some(wasm) = &self.wasm { - let hash = if self.fee.build_only { + let hash = if self.fee.build_only || self.fee.sim_only { wasm::Args { wasm: wasm.clone() }.hash()? } else { install::Cmd { diff --git a/cmd/soroban-cli/src/lib.rs b/cmd/soroban-cli/src/lib.rs index 70c7d7d5f6..f71ce55e2b 100644 --- a/cmd/soroban-cli/src/lib.rs +++ b/cmd/soroban-cli/src/lib.rs @@ -16,6 +16,7 @@ pub mod fee; pub mod get_spec; pub mod key; pub mod log; +pub mod signer; pub mod toid; pub mod utils; pub mod wasm; diff --git a/cmd/soroban-cli/src/signer.rs b/cmd/soroban-cli/src/signer.rs new file mode 100644 index 0000000000..e645166ffb --- /dev/null +++ b/cmd/soroban-cli/src/signer.rs @@ -0,0 +1,260 @@ +use ed25519_dalek::ed25519::signature::Signer; +use sha2::{Digest, Sha256}; +use termion::{event::Key, get_tty, input::TermRead}; + +use soroban_env_host::xdr::{ + self, AccountId, DecoratedSignature, Hash, HashIdPreimage, HashIdPreimageSorobanAuthorization, + InvokeHostFunctionOp, Limits, Operation, OperationBody, PublicKey, ScAddress, ScMap, ScSymbol, + ScVal, Signature, SignatureHint, SorobanAddressCredentials, SorobanAuthorizationEntry, + SorobanAuthorizedFunction, SorobanCredentials, Transaction, TransactionEnvelope, + TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction, + TransactionV1Envelope, Uint256, WriteXdr, +}; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Xdr(#[from] xdr::Error), + #[error("Contract addresses are not supported to sign auth entries {address}")] + ContractAddressAreNotSupported { address: String }, + #[error("User cancelled signing, perhaps need to add -y")] + UserCancelledSigning, +} + +fn requires_auth(txn: &Transaction) -> Option { + let [op @ Operation { + body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }), + .. + }] = txn.operations.as_slice() + else { + return None; + }; + matches!( + auth.first().map(|x| &x.root_invocation.function), + Some(&SorobanAuthorizedFunction::ContractFn(_)) + ) + .then(move || op.clone()) +} + +/// A trait for signing Stellar transactions and Soroban authorization entries +#[allow(async_fn_in_trait)] +pub trait Stellar { + async fn get_public_key(&self) -> Result; + + async fn sign_blob(&self, blob: &[u8]) -> Result, Error>; + + /// Sign a transaction hash with the given source account + /// # Errors + /// Returns an error if the source account is not found + async fn sign_txn_hash(&self, txn: [u8; 32]) -> Result { + let source_account = self.get_public_key().await?; + eprintln!( + "{} about to sign hash: {}", + source_account.to_string(), + hex::encode(txn) + ); + let tx_signature = self.sign_blob(&txn).await?; + Ok(DecoratedSignature { + // TODO: remove this unwrap. It's safe because we know the length of the array + hint: SignatureHint(source_account.0[28..].try_into().unwrap()), + signature: Signature(tx_signature.try_into()?), + }) + } + + /// Sign a Soroban authorization entry with the given address + /// # Errors + /// Returns an error if the address is not found + async fn sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + signature_expiration_ledger: u32, + network_passphrase: &str, + ) -> Result { + let address = self.get_public_key().await?; + let mut auth = unsigned_entry.clone(); + let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(ref mut credentials), + .. + } = auth + else { + // Doesn't need special signing + return Ok(auth); + }; + let SorobanAddressCredentials { nonce, .. } = credentials; + + let preimage = HashIdPreimage::SorobanAuthorization(HashIdPreimageSorobanAuthorization { + network_id: hash(network_passphrase), + invocation: auth.root_invocation.clone(), + nonce: *nonce, + signature_expiration_ledger, + }) + .to_xdr(Limits::none())?; + + let payload = Sha256::digest(preimage); + let signature = self.sign_blob(&payload).await?; + + let map = ScMap::sorted_from(vec![ + ( + ScVal::Symbol(ScSymbol("public_key".try_into()?)), + ScVal::Bytes(address.0.to_vec().try_into()?), + ), + ( + ScVal::Symbol(ScSymbol("signature".try_into()?)), + ScVal::Bytes(signature.try_into()?), + ), + ])?; + credentials.signature = ScVal::Vec(Some(vec![ScVal::Map(Some(map))].try_into()?)); + credentials.signature_expiration_ledger = signature_expiration_ledger; + auth.credentials = SorobanCredentials::Address(credentials.clone()); + + Ok(auth) + } + + /// Sign a Stellar transaction with the given source account + /// This is a default implementation that signs the transaction hash and returns a decorated signature + /// # Errors + /// Returns an error if the source account is not found + async fn sign_txn( + &self, + txn: Transaction, + network_passphrase: &str, + ) -> Result { + let signature_payload = TransactionSignaturePayload { + network_id: hash(network_passphrase), + tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(txn.clone()), + }; + let hash = Sha256::digest(signature_payload.to_xdr(Limits::none())?).into(); + let decorated_signature = self.sign_txn_hash(hash).await?; + Ok(TransactionEnvelope::Tx(TransactionV1Envelope { + tx: txn, + signatures: vec![decorated_signature].try_into()?, + })) + } + + /// Sign a Soroban authorization entries for a given transaction and set the expiration ledger + /// # Errors + /// Returns an error if the address is not found + async fn sign_soroban_authorizations( + &self, + raw: &Transaction, + signature_expiration_ledger: u32, + network_passphrase: &str, + ) -> Result, Error> { + let mut tx = raw.clone(); + let Some(mut op) = requires_auth(&tx) else { + return Ok(None); + }; + + let xdr::Operation { + body: OperationBody::InvokeHostFunction(ref mut body), + .. + } = op + else { + return Ok(None); + }; + + let mut auths = body.auth.to_vec(); + for auth in &mut auths { + *auth = self + .maybe_sign_soroban_authorization_entry( + auth, + signature_expiration_ledger, + network_passphrase, + ) + .await?; + } + body.auth = auths.try_into()?; + tx.operations = vec![op].try_into()?; + Ok(Some(tx)) + } + + /// Sign a Soroban authorization entry if the address is public key + /// # Errors + /// Returns an error if the address in entry is a contract + async fn maybe_sign_soroban_authorization_entry( + &self, + unsigned_entry: &SorobanAuthorizationEntry, + signature_expiration_ledger: u32, + network_passphrase: &str, + ) -> Result { + if let SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { ref address, .. }), + .. + } = unsigned_entry + { + // See if we have a signer for this authorizationEntry + // If not, then we Error + let needle = match address { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(a)))) => { + stellar_strkey::ed25519::PublicKey(*a) + } + ScAddress::Contract(Hash(c)) => { + // This address is for a contract. This means we're using a custom + // smart-contract account. Currently the CLI doesn't support that yet. + return Err(Error::ContractAddressAreNotSupported { + address: stellar_strkey::Strkey::Contract(stellar_strkey::Contract(*c)) + .to_string(), + }); + } + }; + if needle == self.get_public_key().await? { + return Ok(unsigned_entry.clone()); + } + self.sign_soroban_authorization_entry( + unsigned_entry, + signature_expiration_ledger, + network_passphrase, + ) + .await + } else { + Ok(unsigned_entry.clone()) + } + } +} + +fn hash(network_passphrase: &str) -> xdr::Hash { + xdr::Hash(Sha256::digest(network_passphrase.as_bytes()).into()) +} + +pub struct LocalKey { + key: ed25519_dalek::SigningKey, + prompt: bool, +} + +impl LocalKey { + pub fn new(key: ed25519_dalek::SigningKey, prompt: bool) -> Self { + Self { key, prompt } + } +} + +impl Stellar for LocalKey { + async fn sign_blob(&self, data: &[u8]) -> Result, Error> { + if self.prompt { + eprintln!("Press 'y' or 'Y' for yes, any other key for no:"); + match read_key() { + 'y' | 'Y' => (), + _ => return Err(Error::UserCancelledSigning), + }; + } + let sig = self.key.sign(data); + Ok(sig.to_bytes().to_vec()) + } + + async fn get_public_key(&self) -> Result { + Ok(stellar_strkey::ed25519::PublicKey( + self.key.verifying_key().to_bytes(), + )) + } +} + +pub fn read_key() -> char { + let tty = get_tty().unwrap(); + if let Some(key) = tty.keys().next() { + match key.unwrap() { + Key::Char(c) => c, + _ => '_', + } + } else { + ' ' + } +}