diff --git a/cmd/soroban-cli/src/commands/message/mod.rs b/cmd/soroban-cli/src/commands/message/mod.rs new file mode 100644 index 000000000..23133c432 --- /dev/null +++ b/cmd/soroban-cli/src/commands/message/mod.rs @@ -0,0 +1,47 @@ +use crate::commands::global; + +pub mod sign; +pub mod verify; + +/// The prefix used for SEP-53 message signing. +/// See: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md +pub const SEP53_PREFIX: &str = "Stellar Signed Message:\n"; + +#[derive(Debug, clap::Subcommand)] +pub enum Cmd { + /// Sign an arbitrary message using SEP-53 + /// + /// Signs a message following the SEP-53 specification for arbitrary message signing. + /// The message is prefixed with "Stellar Signed Message:\n", hashed with SHA-256, + /// and signed with the ed25519 private key. + /// + /// Example: stellar message sign "Hello, World!" --sign-with-key alice + Sign(sign::Cmd), + + /// Verify a SEP-53 signed message + /// + /// Verifies that a signature was produced by the holder of the private key + /// corresponding to the given public key, following the SEP-53 specification. + /// + /// Example: stellar message verify "Hello, World!" --signature --public-key GABC... + Verify(verify::Cmd), +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Sign(#[from] sign::Error), + + #[error(transparent)] + Verify(#[from] verify::Error), +} + +impl Cmd { + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + match self { + Cmd::Sign(cmd) => cmd.run(global_args).await?, + Cmd::Verify(cmd) => cmd.run()?, + } + Ok(()) + } +} diff --git a/cmd/soroban-cli/src/commands/message/sign.rs b/cmd/soroban-cli/src/commands/message/sign.rs new file mode 100644 index 000000000..c38bb72d5 --- /dev/null +++ b/cmd/soroban-cli/src/commands/message/sign.rs @@ -0,0 +1,324 @@ +use std::io::{self, Read}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use clap::Parser; +use ed25519_dalek::Signer as _; +use sha2::{Digest, Sha256}; + +use crate::{ + commands::global, + config::{locator, secret}, + signer::{self, SecureStoreEntry}, +}; + +use super::SEP53_PREFIX; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), + + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + Signer(#[from] signer::Error), + + #[error(transparent)] + Io(#[from] io::Error), + + #[error(transparent)] + Base64(#[from] base64::DecodeError), + + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), + + #[error(transparent)] + Ed25519(#[from] ed25519_dalek::SignatureError), + + #[error("No signing key provided. Use --sign-with-key")] + NoSigningKey, + + #[error("Ledger signing of arbitrary messages is not yet supported")] + LedgerNotSupported, +} + +#[derive(Debug, Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// The message to sign. If not provided, reads from stdin. + #[arg()] + pub message: Option, + + /// Treat the message as base64-encoded binary data + #[arg(long)] + pub base64: bool, + + /// Sign with a local key. Can be an identity (--sign-with-key alice), + /// a secret key (--sign-with-key SC36...), or a seed phrase + /// (--sign-with-key "kite urban..."). + #[arg(long, env = "STELLAR_SIGN_WITH_KEY")] + pub sign_with_key: Option, + + /// If using a seed phrase to sign, sets which hierarchical deterministic + /// path to use, e.g. `m/44'/148'/{hd_path}`. Example: `--hd-path 1`. Default: `0` + #[arg(long)] + pub hd_path: Option, + + /// Sign with a Ledger hardware wallet + #[arg( + long, + conflicts_with = "sign_with_key", + env = "STELLAR_SIGN_WITH_LEDGER" + )] + pub sign_with_ledger: bool, + + #[command(flatten)] + pub locator: locator::Args, +} + +/// Output format for signed messages +#[derive(serde::Serialize)] +struct SignedMessageOutput { + /// The public key (address) that signed the message + signer: String, + /// The original message (as provided or base64 if binary) + message: String, + /// The base64-encoded signature + signature: String, +} + +impl Cmd { + #[allow(clippy::unused_async)] + pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> { + // Get the message bytes + let message_bytes = self.get_message_bytes()?; + + // Create the SEP-53 payload: prefix + message + let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len()); + payload.extend_from_slice(SEP53_PREFIX.as_bytes()); + payload.extend_from_slice(&message_bytes); + + // Hash the payload with SHA-256 + let hash: [u8; 32] = Sha256::digest(&payload).into(); + + // Get the signer and sign + let (public_key, signature) = self.sign_hash(hash)?; + + // Encode signature as base64 + let signature_base64 = BASE64.encode(signature.to_bytes()); + + // Output the result + let output = SignedMessageOutput { + signer: public_key.to_string(), + message: if self.base64 { + BASE64.encode(&message_bytes) + } else { + String::from_utf8_lossy(&message_bytes).to_string() + }, + signature: signature_base64.clone(), + }; + + if global_args.quiet { + // In quiet mode, just output the signature + println!("{signature_base64}"); + } else { + // Output as formatted text + println!("Signer: {}", output.signer); + println!("Signature: {}", output.signature); + } + + Ok(()) + } + + fn sign_hash( + &self, + hash: [u8; 32], + ) -> Result<(stellar_strkey::ed25519::PublicKey, ed25519_dalek::Signature), Error> { + if self.sign_with_ledger { + // Ledger doesn't support signing arbitrary messages yet + return Err(Error::LedgerNotSupported); + } + + let key_or_name = self.sign_with_key.as_deref().ok_or(Error::NoSigningKey)?; + let secret = self.locator.get_secret_key(key_or_name)?; + + match &secret { + secret::Secret::SecretKey { .. } | secret::Secret::SeedPhrase { .. } => { + let signing_key = secret.key_pair(self.hd_path)?; + let public_key = stellar_strkey::ed25519::PublicKey::from_payload( + signing_key.verifying_key().as_bytes(), + )?; + let signature = signing_key.sign(&hash); + Ok((public_key, signature)) + } + secret::Secret::Ledger => { + // Ledger doesn't support signing arbitrary messages yet + Err(Error::LedgerNotSupported) + } + secret::Secret::SecureStore { entry_name } => { + let entry = SecureStoreEntry { + name: entry_name.clone(), + hd_path: self.hd_path, + }; + let public_key = entry.get_public_key()?; + let signature = entry.sign_payload(hash)?; + Ok((public_key, signature)) + } + } + } + + fn get_message_bytes(&self) -> Result, Error> { + let message_str = if let Some(msg) = &self.message { + msg.clone() + } else { + // Read from stdin + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + // Remove trailing newline if present + if buffer.ends_with('\n') { + buffer.pop(); + if buffer.ends_with('\r') { + buffer.pop(); + } + } + buffer + }; + + if self.base64 { + // Decode base64 input + Ok(BASE64.decode(&message_str)?) + } else { + // Use UTF-8 encoded message + Ok(message_str.into_bytes()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::secret::Secret; + use std::str::FromStr; + + // Use a known valid test key from the codebase + const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH"; + const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ"; + + fn get_test_signing_key() -> ed25519_dalek::SigningKey { + let secret = Secret::from_str(TEST_SECRET_KEY).unwrap(); + secret.key_pair(None).unwrap() + } + + fn sign_message(message_bytes: &[u8], signing_key: &ed25519_dalek::SigningKey) -> String { + // Create SEP-53 payload + let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len()); + payload.extend_from_slice(SEP53_PREFIX.as_bytes()); + payload.extend_from_slice(message_bytes); + + // Hash with SHA-256 + let hash: [u8; 32] = Sha256::digest(&payload).into(); + + // Sign + let signature = signing_key.sign(&hash); + + // Return base64-encoded signature + BASE64.encode(signature.to_bytes()) + } + + fn verify_signature( + message_bytes: &[u8], + signature_base64: &str, + signing_key: &ed25519_dalek::SigningKey, + ) -> bool { + use ed25519_dalek::Verifier; + + // Create SEP-53 payload + let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len()); + payload.extend_from_slice(SEP53_PREFIX.as_bytes()); + payload.extend_from_slice(message_bytes); + + // Hash with SHA-256 + let hash: [u8; 32] = Sha256::digest(&payload).into(); + + // Decode signature + let signature_bytes = BASE64.decode(signature_base64).unwrap(); + let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap(); + + // Verify + signing_key + .verifying_key() + .verify(&hash, &signature) + .is_ok() + } + + #[test] + fn test_sign_and_verify_ascii_message() { + let signing_key = get_test_signing_key(); + + // Verify public key matches expected + let public_key = stellar_strkey::ed25519::PublicKey::from_payload( + signing_key.verifying_key().as_bytes(), + ) + .unwrap(); + assert_eq!(public_key.to_string(), TEST_PUBLIC_KEY); + + // Sign and verify + let message = "Hello, World!"; + let signature = sign_message(message.as_bytes(), &signing_key); + assert!(verify_signature( + message.as_bytes(), + &signature, + &signing_key + )); + } + + #[test] + fn test_sign_and_verify_utf8_message() { + let signing_key = get_test_signing_key(); + + // Sign and verify Japanese text + let message = "こんにちは、世界!"; + let signature = sign_message(message.as_bytes(), &signing_key); + assert!(verify_signature( + message.as_bytes(), + &signature, + &signing_key + )); + } + + #[test] + fn test_sign_and_verify_binary_message() { + let signing_key = get_test_signing_key(); + + // Sign and verify binary data + let message_base64 = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo="; + let message_bytes = BASE64.decode(message_base64).unwrap(); + let signature = sign_message(&message_bytes, &signing_key); + assert!(verify_signature(&message_bytes, &signature, &signing_key)); + } + + #[test] + fn test_sep53_prefix_is_correct() { + // Verify the SEP-53 prefix is as specified + assert_eq!(SEP53_PREFIX, "Stellar Signed Message:\n"); + } + + #[test] + fn test_wrong_signature_fails_verification() { + let signing_key = get_test_signing_key(); + + let message1 = "Hello, World!"; + let message2 = "Goodbye, World!"; + + // Sign message1 + let signature = sign_message(message1.as_bytes(), &signing_key); + + // Verify fails with different message + assert!(!verify_signature( + message2.as_bytes(), + &signature, + &signing_key + )); + } +} diff --git a/cmd/soroban-cli/src/commands/message/verify.rs b/cmd/soroban-cli/src/commands/message/verify.rs new file mode 100644 index 000000000..b75884e4a --- /dev/null +++ b/cmd/soroban-cli/src/commands/message/verify.rs @@ -0,0 +1,229 @@ +use std::io::{self, Read}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use clap::Parser; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256}; + +use crate::config::{locator, secret}; + +use super::SEP53_PREFIX; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Locator(#[from] locator::Error), + + #[error(transparent)] + Secret(#[from] secret::Error), + + #[error(transparent)] + Io(#[from] io::Error), + + #[error(transparent)] + Base64(#[from] base64::DecodeError), + + #[error(transparent)] + StrKey(#[from] stellar_strkey::DecodeError), + + #[error(transparent)] + Ed25519(#[from] ed25519_dalek::SignatureError), + + #[error("Signature verification failed")] + VerificationFailed, + + #[error("Invalid signature length: expected 64 bytes, got {0}")] + InvalidSignatureLength(usize), +} + +#[derive(Debug, Parser, Clone)] +#[group(skip)] +pub struct Cmd { + /// The message to verify. If not provided, reads from stdin. + #[arg()] + pub message: Option, + + /// The base64-encoded signature to verify + #[arg(long, short = 's')] + pub signature: String, + + /// The public key to verify against. + /// Can be a Stellar public key (G...) or an identity name. + #[arg(long, short = 'p')] + pub public_key: String, + + /// Treat the message as base64-encoded binary data + #[arg(long)] + pub base64: bool, + + #[command(flatten)] + pub locator: locator::Args, +} + +impl Cmd { + pub fn run(&self) -> Result<(), Error> { + // Get the message bytes + let message_bytes = self.get_message_bytes()?; + + // Create the SEP-53 payload: prefix + message + let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len()); + payload.extend_from_slice(SEP53_PREFIX.as_bytes()); + payload.extend_from_slice(&message_bytes); + + // Hash the payload with SHA-256 + let hash: [u8; 32] = Sha256::digest(&payload).into(); + + // Decode the signature + let signature_bytes = BASE64.decode(&self.signature)?; + if signature_bytes.len() != 64 { + return Err(Error::InvalidSignatureLength(signature_bytes.len())); + } + let signature = Signature::from_slice(&signature_bytes)?; + + // Get the public key + let public_key_bytes = self.get_public_key_bytes()?; + let verifying_key = VerifyingKey::from_bytes(&public_key_bytes)?; + + // Verify the signature + if verifying_key.verify(&hash, &signature).is_ok() { + let public_key = stellar_strkey::ed25519::PublicKey(public_key_bytes); + println!("Signature valid"); + println!("Signer: {public_key}"); + Ok(()) + } else { + eprintln!("Signature invalid"); + Err(Error::VerificationFailed) + } + } + + fn get_message_bytes(&self) -> Result, Error> { + let message_str = if let Some(msg) = &self.message { + msg.clone() + } else { + // Read from stdin + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + // Remove trailing newline if present + if buffer.ends_with('\n') { + buffer.pop(); + if buffer.ends_with('\r') { + buffer.pop(); + } + } + buffer + }; + + if self.base64 { + // Decode base64 input + Ok(BASE64.decode(&message_str)?) + } else { + // Use UTF-8 encoded message + Ok(message_str.into_bytes()) + } + } + + fn get_public_key_bytes(&self) -> Result<[u8; 32], Error> { + // First, try to parse as a Stellar public key directly + if let Ok(pk) = stellar_strkey::ed25519::PublicKey::from_string(&self.public_key) { + return Ok(pk.0); + } + + // Otherwise, try to look it up as an identity + let secret = self.locator.get_secret_key(&self.public_key)?; + let pk = secret.public_key(None)?; + Ok(pk.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test vectors from SEP-53 + const TEST_PUBLIC_KEY: &str = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + + // Test case 1: ASCII message + const TEST_MESSAGE_1: &str = "Hello, World!"; + const TEST_SIGNATURE_1: &str = + "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + + // Test case 2: Japanese text (UTF-8) + const TEST_MESSAGE_2: &str = "こんにちは、世界!"; + const TEST_SIGNATURE_2: &str = + "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA=="; + + // Test case 3: Binary data (base64 encoded in test vector) + const TEST_MESSAGE_3_BASE64: &str = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo="; + const TEST_SIGNATURE_3: &str = + "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ=="; + + fn verify_message(message_bytes: &[u8], signature_base64: &str, public_key_str: &str) -> bool { + // Create SEP-53 payload + let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len()); + payload.extend_from_slice(SEP53_PREFIX.as_bytes()); + payload.extend_from_slice(message_bytes); + + // Hash with SHA-256 + let hash: [u8; 32] = Sha256::digest(&payload).into(); + + // Decode signature + let signature_bytes = BASE64.decode(signature_base64).unwrap(); + let signature = Signature::from_slice(&signature_bytes).unwrap(); + + // Decode public key + let public_key = stellar_strkey::ed25519::PublicKey::from_string(public_key_str).unwrap(); + let verifying_key = VerifyingKey::from_bytes(&public_key.0).unwrap(); + + // Verify + verifying_key.verify(&hash, &signature).is_ok() + } + + #[test] + fn test_verify_ascii_message() { + assert!(verify_message( + TEST_MESSAGE_1.as_bytes(), + TEST_SIGNATURE_1, + TEST_PUBLIC_KEY + )); + } + + #[test] + fn test_verify_utf8_message() { + assert!(verify_message( + TEST_MESSAGE_2.as_bytes(), + TEST_SIGNATURE_2, + TEST_PUBLIC_KEY + )); + } + + #[test] + fn test_verify_binary_message() { + let message_bytes = BASE64.decode(TEST_MESSAGE_3_BASE64).unwrap(); + assert!(verify_message( + &message_bytes, + TEST_SIGNATURE_3, + TEST_PUBLIC_KEY + )); + } + + #[test] + fn test_verify_wrong_signature() { + // Use signature from message 2 with message 1 + assert!(!verify_message( + TEST_MESSAGE_1.as_bytes(), + TEST_SIGNATURE_2, + TEST_PUBLIC_KEY + )); + } + + #[test] + fn test_verify_wrong_public_key() { + // Use a different public key + let wrong_key = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + assert!(!verify_message( + TEST_MESSAGE_1.as_bytes(), + TEST_SIGNATURE_1, + wrong_key + )); + } +} diff --git a/cmd/soroban-cli/src/commands/mod.rs b/cmd/soroban-cli/src/commands/mod.rs index 4e83f13e6..2b5244617 100644 --- a/cmd/soroban-cli/src/commands/mod.rs +++ b/cmd/soroban-cli/src/commands/mod.rs @@ -18,6 +18,7 @@ pub mod fees; pub mod global; pub mod keys; pub mod ledger; +pub mod message; pub mod network; pub mod plugin; pub mod snapshot; @@ -139,6 +140,7 @@ impl Root { Cmd::Keys(id) => id.run(&self.global_args).await?, Cmd::Tx(tx) => tx.run(&self.global_args).await?, Cmd::Ledger(ledger) => ledger.run(&self.global_args).await?, + Cmd::Message(message) => message.run(&self.global_args).await?, Cmd::Cache(cache) => cache.run()?, Cmd::Env(env) => env.run(&self.global_args)?, Cmd::Fees(env) => env.run(&self.global_args).await?, @@ -227,6 +229,10 @@ pub enum Cmd { #[command(subcommand)] Ledger(ledger::Cmd), + /// Sign and verify arbitrary messages using SEP-53 + #[command(subcommand)] + Message(message::Cmd), + /// ⚠️ Deprecated, use `fees stats` instead. Fetch network feestats FeeStats(fee_stats::Cmd), @@ -289,6 +295,9 @@ pub enum Error { #[error(transparent)] Ledger(#[from] ledger::Error), + #[error(transparent)] + Message(#[from] message::Error), + #[error(transparent)] FeeStats(#[from] fee_stats::Error),