From 3d5bf2c1f479209ee47f6dbdbab5e85005838280 Mon Sep 17 00:00:00 2001 From: Tomer Date: Tue, 23 Dec 2025 18:19:48 -0500 Subject: [PATCH 1/4] feat: add SEP-53 message signing and verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for signing and verifying arbitrary messages per SEP-53. New commands: - `stellar message sign` - Sign a message using SEP-53 specification - `stellar message verify` - Verify a SEP-53 signed message Features: - Sign messages with local keys, seed phrases, or secure store - Support for both UTF-8 text and binary (base64) input - Signature output in base64 format - Identity lookup for signing keys Implementation follows the SEP-53 specification: 1. Prepend "Stellar Signed Message:\n" prefix to message 2. SHA-256 hash the prefixed payload 3. ed25519 sign the hash Closes #2345 πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/soroban-cli/src/commands/message/mod.rs | 47 +++ cmd/soroban-cli/src/commands/message/sign.rs | 305 ++++++++++++++++++ .../src/commands/message/verify.rs | 233 +++++++++++++ cmd/soroban-cli/src/commands/mod.rs | 9 + 4 files changed, 594 insertions(+) create mode 100644 cmd/soroban-cli/src/commands/message/mod.rs create mode 100644 cmd/soroban-cli/src/commands/message/sign.rs create mode 100644 cmd/soroban-cli/src/commands/message/verify.rs 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..01cc4bfe7 --- /dev/null +++ b/cmd/soroban-cli/src/commands/message/sign.rs @@ -0,0 +1,305 @@ +use std::io::{self, Read}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use clap::{arg, 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 { + 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 = match &self.message { + Some(msg) => msg.clone(), + None => { + // 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..f23867750 --- /dev/null +++ b/cmd/soroban-cli/src/commands/message/verify.rs @@ -0,0 +1,233 @@ +use std::io::{self, Read}; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use clap::{arg, 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 + match verifying_key.verify(&hash, &signature) { + Ok(()) => { + let public_key = stellar_strkey::ed25519::PublicKey(public_key_bytes); + println!("Signature valid"); + println!("Signer: {public_key}"); + Ok(()) + } + Err(_) => { + eprintln!("Signature invalid"); + Err(Error::VerificationFailed) + } + } + } + + fn get_message_bytes(&self) -> Result, Error> { + let message_str = match &self.message { + Some(msg) => msg.clone(), + None => { + // 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), From 104ab596729f147da7bb00ba925f7961629ff40d Mon Sep 17 00:00:00 2001 From: Tomer Date: Tue, 23 Dec 2025 18:37:30 -0500 Subject: [PATCH 2/4] fix: address clippy warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused `arg` import from clap - Use `if let` instead of `match` for single pattern destructuring - Add `#[allow(clippy::unused_async)]` for async fn without await (kept async for consistency with other commands) πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/soroban-cli/src/commands/message/sign.rs | 26 +++++------ .../src/commands/message/verify.rs | 44 +++++++++---------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/cmd/soroban-cli/src/commands/message/sign.rs b/cmd/soroban-cli/src/commands/message/sign.rs index 01cc4bfe7..d277d9db2 100644 --- a/cmd/soroban-cli/src/commands/message/sign.rs +++ b/cmd/soroban-cli/src/commands/message/sign.rs @@ -1,7 +1,7 @@ use std::io::{self, Read}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use clap::{arg, Parser}; +use clap::Parser; use ed25519_dalek::Signer as _; use sha2::{Digest, Sha256}; @@ -85,6 +85,7 @@ struct SignedMessageOutput { } 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()?; @@ -164,21 +165,20 @@ impl Cmd { } fn get_message_bytes(&self) -> Result, Error> { - let message_str = match &self.message { - Some(msg) => msg.clone(), - None => { - // 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') { + 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(); - if buffer.ends_with('\r') { - buffer.pop(); - } } - buffer } + buffer }; if self.base64 { diff --git a/cmd/soroban-cli/src/commands/message/verify.rs b/cmd/soroban-cli/src/commands/message/verify.rs index f23867750..7fe8d46ba 100644 --- a/cmd/soroban-cli/src/commands/message/verify.rs +++ b/cmd/soroban-cli/src/commands/message/verify.rs @@ -1,7 +1,7 @@ use std::io::{self, Read}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use clap::{arg, Parser}; +use clap::Parser; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use sha2::{Digest, Sha256}; @@ -85,36 +85,32 @@ impl Cmd { let verifying_key = VerifyingKey::from_bytes(&public_key_bytes)?; // Verify the signature - match verifying_key.verify(&hash, &signature) { - Ok(()) => { - let public_key = stellar_strkey::ed25519::PublicKey(public_key_bytes); - println!("Signature valid"); - println!("Signer: {public_key}"); - Ok(()) - } - Err(_) => { - eprintln!("Signature invalid"); - Err(Error::VerificationFailed) - } + 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 = match &self.message { - Some(msg) => msg.clone(), - None => { - // 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') { + 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(); - if buffer.ends_with('\r') { - buffer.pop(); - } } - buffer } + buffer }; if self.base64 { From c0b97bf1f3685626e1af0b531b54b3e9cde75d67 Mon Sep 17 00:00:00 2001 From: Tomer Weller Date: Tue, 23 Dec 2025 18:52:19 -0500 Subject: [PATCH 3/4] Fix cargo fmt formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/soroban-cli/src/commands/message/sign.rs | 29 +++++++++++++++---- .../src/commands/message/verify.rs | 12 ++++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/cmd/soroban-cli/src/commands/message/sign.rs b/cmd/soroban-cli/src/commands/message/sign.rs index d277d9db2..c38bb72d5 100644 --- a/cmd/soroban-cli/src/commands/message/sign.rs +++ b/cmd/soroban-cli/src/commands/message/sign.rs @@ -66,7 +66,11 @@ pub struct Cmd { pub hd_path: Option, /// Sign with a Ledger hardware wallet - #[arg(long, conflicts_with = "sign_with_key", env = "STELLAR_SIGN_WITH_LEDGER")] + #[arg( + long, + conflicts_with = "sign_with_key", + env = "STELLAR_SIGN_WITH_LEDGER" + )] pub sign_with_ledger: bool, #[command(flatten)] @@ -242,7 +246,10 @@ mod tests { let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap(); // Verify - signing_key.verifying_key().verify(&hash, &signature).is_ok() + signing_key + .verifying_key() + .verify(&hash, &signature) + .is_ok() } #[test] @@ -259,7 +266,11 @@ mod tests { // 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)); + assert!(verify_signature( + message.as_bytes(), + &signature, + &signing_key + )); } #[test] @@ -269,7 +280,11 @@ mod tests { // 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)); + assert!(verify_signature( + message.as_bytes(), + &signature, + &signing_key + )); } #[test] @@ -300,6 +315,10 @@ mod tests { let signature = sign_message(message1.as_bytes(), &signing_key); // Verify fails with different message - assert!(!verify_signature(message2.as_bytes(), &signature, &signing_key)); + 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 index 7fe8d46ba..b75884e4a 100644 --- a/cmd/soroban-cli/src/commands/message/verify.rs +++ b/cmd/soroban-cli/src/commands/message/verify.rs @@ -157,11 +157,7 @@ mod tests { 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 { + 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()); @@ -203,7 +199,11 @@ mod tests { #[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)); + assert!(verify_message( + &message_bytes, + TEST_SIGNATURE_3, + TEST_PUBLIC_KEY + )); } #[test] From 57987637f02d3e02fb8f49e737477b23a47add08 Mon Sep 17 00:00:00 2001 From: mootz12 Date: Thu, 8 Jan 2026 09:44:51 -0500 Subject: [PATCH 4/4] chore: improve message commands and add tests --- cmd/crates/soroban-test/tests/it/main.rs | 1 + cmd/crates/soroban-test/tests/it/message.rs | 177 ++++++++++ cmd/soroban-cli/src/commands/message/mod.rs | 9 +- cmd/soroban-cli/src/commands/message/sign.rs | 309 ++++++------------ .../src/commands/message/verify.rs | 235 +++++++------ cmd/soroban-cli/src/print.rs | 9 + cmd/soroban-cli/src/signer/mod.rs | 17 +- 7 files changed, 450 insertions(+), 307 deletions(-) create mode 100644 cmd/crates/soroban-test/tests/it/message.rs diff --git a/cmd/crates/soroban-test/tests/it/main.rs b/cmd/crates/soroban-test/tests/it/main.rs index 6cbf1a60d..e96558af5 100644 --- a/cmd/crates/soroban-test/tests/it/main.rs +++ b/cmd/crates/soroban-test/tests/it/main.rs @@ -7,6 +7,7 @@ mod init; #[cfg(feature = "it")] mod integration; mod log; +mod message; mod plugin; mod rpc_provider; mod strkey; diff --git a/cmd/crates/soroban-test/tests/it/message.rs b/cmd/crates/soroban-test/tests/it/message.rs new file mode 100644 index 000000000..9d26f3fdf --- /dev/null +++ b/cmd/crates/soroban-test/tests/it/message.rs @@ -0,0 +1,177 @@ +use soroban_test::{AssertExt, TestEnv}; + +#[tokio::test] +async fn sep_53_sign_message_and_verify() { + let sandbox = &TestEnv::new(); + + let message = "Hello, World!"; + let expected_signature = + "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + let wrong_signature = + "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA=="; + let secret_key = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW"; + let public_key = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + let wrong_public_key = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ"; + + let output = sandbox + .new_assert_cmd("message") + .args(["sign", message, "--sign-with-key", secret_key]) + .assert() + .success() + .stdout_as_str(); + assert_eq!(output.trim(), expected_signature); + + sandbox + .new_assert_cmd("message") + .args([ + "verify", + message, + "--signature", + expected_signature, + "--public-key", + public_key, + ]) + .assert() + .success(); + + // wrong signature + sandbox + .new_assert_cmd("message") + .args([ + "verify", + message, + "--signature", + wrong_signature, + "--public-key", + public_key, + ]) + .assert() + .failure(); + + // wrong public key + sandbox + .new_assert_cmd("message") + .args([ + "verify", + message, + "--signature", + expected_signature, + "--public-key", + wrong_public_key, + ]) + .assert() + .failure(); +} + +#[tokio::test] +async fn sep_53_sign_message_and_verify_stdin() { + let sandbox = &TestEnv::new(); + + let message = "Hello, World!"; + let expected_signature = + "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + let secret_key = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW"; + let public_key = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + + // sandbox + // .new_assert_cmd("keys") + // .args(["add", alias_secret, "--secret-key", secret_key]) + // .assert() + // .success(); + // sandbox + // .new_assert_cmd("keys") + // .args(["add", alias_public, "--public-key", public_key]) + // .assert() + // .success(); + + let output = sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args(["sign", "--sign-with-key", secret_key]) + .assert() + .success() + .stdout_as_str(); + assert_eq!(output.trim(), expected_signature); + + sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args([ + "verify", + "--signature", + expected_signature, + "--public-key", + public_key, + ]) + .assert() + .success(); +} + +#[tokio::test] +async fn sep_53_sign_message_and_verify_with_alias() { + let sandbox = &TestEnv::new(); + + let message = "Hello, World!"; + let expected_signature = + "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + let public_key = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + + // generate a new secret "alice" and a public alias "bob" of the example pubkey + sandbox + .new_assert_cmd("keys") + .args(["generate", "alice"]) + .assert() + .success(); + sandbox + .new_assert_cmd("keys") + .args(["add", "bob", "--public-key", public_key]) + .assert() + .success(); + + // since this is randomly generated, just validate the output matches for alice + let alice_signature = sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args(["sign", "--sign-with-key", "alice"]) + .assert() + .success() + .stdout_as_str(); + sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args([ + "verify", + "--signature", + &alice_signature, + "--public-key", + "alice", + ]) + .assert() + .success(); + + // validate a public key alias works for validation + sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args([ + "verify", + "--signature", + expected_signature, + "--public-key", + "bob", + ]) + .assert() + .success(); + sandbox + .new_assert_cmd("message") + .write_stdin(message) + .args([ + "verify", + "--signature", + &alice_signature, + "--public-key", + "bob", + ]) + .assert() + .failure(); +} diff --git a/cmd/soroban-cli/src/commands/message/mod.rs b/cmd/soroban-cli/src/commands/message/mod.rs index 23133c432..033d98e03 100644 --- a/cmd/soroban-cli/src/commands/message/mod.rs +++ b/cmd/soroban-cli/src/commands/message/mod.rs @@ -12,7 +12,7 @@ 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, + /// The provided message will get 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 @@ -21,9 +21,10 @@ pub enum 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. + /// corresponding to the given account public key, following the SEP-53 specification. The + /// provided message will get prefixed with "Stellar Signed Message:\n" before verification. /// - /// Example: stellar message verify "Hello, World!" --signature --public-key GABC... + /// Example: stellar message verify "Hello, World!" --signature --account GABC... Verify(verify::Cmd), } @@ -40,7 +41,7 @@ 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()?, + Cmd::Verify(cmd) => cmd.run(global_args)?, } Ok(()) } diff --git a/cmd/soroban-cli/src/commands/message/sign.rs b/cmd/soroban-cli/src/commands/message/sign.rs index c38bb72d5..52156cbd4 100644 --- a/cmd/soroban-cli/src/commands/message/sign.rs +++ b/cmd/soroban-cli/src/commands/message/sign.rs @@ -2,13 +2,13 @@ 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}, + print::Print, + signer::{self, Signer}, }; use super::SEP53_PREFIX; @@ -46,7 +46,8 @@ pub enum Error { #[derive(Debug, Parser, Clone)] #[group(skip)] pub struct Cmd { - /// The message to sign. If not provided, reads from stdin. + /// The message to sign. If not provided, reads from stdin. This should **not** include + /// the SEP-53 prefix "Stellar Signed Message:\n", as it will be added automatically. #[arg()] pub message: Option, @@ -54,120 +55,46 @@ pub struct Cmd { #[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..."). + // @dev: Ledger and Lab don't support signing arbitrary messages yet. Once they do, use `sign_with::Args` here. + /// Sign with a local key or key saved in OS secure storage. Can be an identity (--sign-with-key alice), a secret key (--sign-with-key SC36…), or a seed phrase (--sign-with-key "kite urban…"). If using seed phrase, `--hd-path` defaults to the `0` path. #[arg(long, env = "STELLAR_SIGN_WITH_KEY")] - pub sign_with_key: Option, + pub sign_with_key: String, - /// 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)] + /// 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` 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> { + let print = Print::new(global_args.quiet); + // 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)?; + // Get the signer + let key_or_name = &self.sign_with_key; + let secret = self.locator.get_secret_key(key_or_name)?; + let signer = secret.signer(self.hd_path, print.clone()).await?; + let public_key = signer.get_public_key()?; // 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(), - }; + let signature_base64 = sep_53_sign(&message_bytes, signer)?; - if global_args.quiet { - // In quiet mode, just output the signature - println!("{signature_base64}"); + print.infoln(format!("Signer: {public_key}")); + let message_display = if self.base64 { + BASE64.encode(&message_bytes) } else { - // Output as formatted text - println!("Signer: {}", output.signer); - println!("Signature: {}", output.signature); - } - + String::from_utf8_lossy(&message_bytes).to_string() + }; + print.infoln(format!("Message: {message_display}")); + print.println_stdout(signature_base64); 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() @@ -195,130 +122,112 @@ impl Cmd { } } -#[cfg(test)] -mod tests { - use super::*; - use crate::config::secret::Secret; - use std::str::FromStr; +/// Sign the given message bytes with the provided signer, returning the base64-encoded signature. +/// +/// Expects the message bytes to be the raw message (without SEP-53 prefix). +fn sep_53_sign(message_bytes: &[u8], signer: Signer) -> Result { + // 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); + let hash: [u8; 32] = Sha256::digest(&payload).into(); - // Use a known valid test key from the codebase - const TEST_SECRET_KEY: &str = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH"; - const TEST_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ"; + let signature = signer.sign_payload(hash)?; - fn get_test_signing_key() -> ed25519_dalek::SigningKey { - let secret = Secret::from_str(TEST_SECRET_KEY).unwrap(); - secret.key_pair(None).unwrap() - } + Ok(BASE64.encode(signature.to_bytes())) +} - 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); +#[cfg(test)] +mod tests { + use std::str::FromStr; - // Hash with SHA-256 - let hash: [u8; 32] = Sha256::digest(&payload).into(); + use super::*; + use crate::{config::secret::Secret, utils::into_signing_key}; - // Sign - let signature = signing_key.sign(&hash); + // Public key = GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L + const TEST_SECRET_KEY: &str = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW"; - // Return base64-encoded signature - BASE64.encode(signature.to_bytes()) + fn setup_locator() -> locator::Args { + let temp_dir = tempfile::tempdir().unwrap(); + locator::Args { + global: false, + config_dir: Some(temp_dir.path().to_path_buf()), + } } - 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() + fn build_signer_for_test_key() -> Signer { + let secret = Secret::from_str(TEST_SECRET_KEY).unwrap(); + let private_key = secret.private_key(None).unwrap(); + let signing_key = into_signing_key(&private_key); + Signer { + kind: signer::SignerKind::Local(signer::LocalKey { key: signing_key }), + print: Print::new(true), + } } #[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 - )); - } + fn test_sign_simple() { + // SEP-53 - test case 1 + let message = "Hello, World!".to_string(); + let expected_signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + sign_with_key: TEST_SECRET_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let signer = build_signer_for_test_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 - )); - } + let message_bytes = cmd.get_message_bytes().unwrap(); + let signature_base64 = sep_53_sign(&message_bytes, signer).unwrap(); - #[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)); + assert_eq!(signature_base64, expected_signature); } #[test] - fn test_sep53_prefix_is_correct() { - // Verify the SEP-53 prefix is as specified - assert_eq!(SEP53_PREFIX, "Stellar Signed Message:\n"); + fn test_sign_japanese() { + // SEP-53 - test case 2 + let message = "γ“γ‚“γ«γ‘γ―γ€δΈ–η•ŒοΌ".to_string(); + let expected_signature = "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA=="; + + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + sign_with_key: TEST_SECRET_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let signer = build_signer_for_test_key(); + + let message_bytes = cmd.get_message_bytes().unwrap(); + let signature_base64 = sep_53_sign(&message_bytes, signer).unwrap(); + + assert_eq!(signature_base64, expected_signature); } #[test] - fn test_wrong_signature_fails_verification() { - let signing_key = get_test_signing_key(); - - let message1 = "Hello, World!"; - let message2 = "Goodbye, World!"; + fn test_sign_base64() { + // SEP-53 - test case 3 + let message = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=".to_string(); + let expected_signature = "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ=="; + + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: true, + sign_with_key: TEST_SECRET_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let signer = build_signer_for_test_key(); - // Sign message1 - let signature = sign_message(message1.as_bytes(), &signing_key); + let message_bytes = cmd.get_message_bytes().unwrap(); + let signature_base64 = sep_53_sign(&message_bytes, signer).unwrap(); - // Verify fails with different message - assert!(!verify_signature( - message2.as_bytes(), - &signature, - &signing_key - )); + assert_eq!(signature_base64, expected_signature); } } diff --git a/cmd/soroban-cli/src/commands/message/verify.rs b/cmd/soroban-cli/src/commands/message/verify.rs index b75884e4a..beb44d0db 100644 --- a/cmd/soroban-cli/src/commands/message/verify.rs +++ b/cmd/soroban-cli/src/commands/message/verify.rs @@ -1,12 +1,15 @@ use std::io::{self, Read}; +use crate::{ + commands::global, + config::{locator, secret}, + print::Print, +}; 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)] @@ -29,6 +32,9 @@ pub enum Error { #[error(transparent)] Ed25519(#[from] ed25519_dalek::SignatureError), + #[error(transparent)] + Address(#[from] crate::config::address::Error), + #[error("Signature verification failed")] VerificationFailed, @@ -39,33 +45,38 @@ pub enum Error { #[derive(Debug, Parser, Clone)] #[group(skip)] pub struct Cmd { - /// The message to verify. If not provided, reads from stdin. + /// The message to verify. If not provided, reads from stdin. This should **not** include + /// the SEP-53 prefix "Stellar Signed Message:\n", as it will be added automatically. #[arg()] pub message: Option, + /// Treat the message as base64-encoded binary data + #[arg(long)] + pub base64: bool, + /// 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. + /// The public key to verify the signature against. Can be an identity (--public_key alice), + /// a public key (--public_key GDKW...). #[arg(long, short = 'p')] pub public_key: String, - /// Treat the message as base64-encoded binary data + /// If public key identity is a seed phrase use this hd path, default is 0 #[arg(long)] - pub base64: bool, + pub hd_path: Option, #[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()?; + pub fn run(&self, global_args: &global::Args) -> Result<(), Error> { + let print = Print::new(global_args.quiet); - // Create the SEP-53 payload: prefix + message + // Create the SEP-53 payload: prefix + message as utf-8 byte array + let message_bytes = self.get_message_bytes()?; 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); @@ -80,18 +91,17 @@ impl Cmd { } 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)?; + // Get the verifying key + let public_key = self.get_public_key()?; + print.infoln(format!("Verifying signature against: {public_key}")); + let verifying_key = VerifyingKey::from_bytes(&public_key.0)?; // 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}"); + print.checkln("Signature valid"); Ok(()) } else { - eprintln!("Signature invalid"); + print.errorln("Signature invalid"); Err(Error::VerificationFailed) } } @@ -122,16 +132,23 @@ impl Cmd { } } - fn get_public_key_bytes(&self) -> Result<[u8; 32], Error> { - // First, try to parse as a Stellar public key directly + fn get_public_key(&self) -> Result { + // try to parse as stellar public key first if let Ok(pk) = stellar_strkey::ed25519::PublicKey::from_string(&self.public_key) { - return Ok(pk.0); + return Ok(pk); } - // 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) + // otherwise treat as identity and resolve + let account = self + .locator + .read_key(&self.public_key)? + .muxed_account(self.hd_path) + .map_err(crate::config::address::Error::from)?; + let bytes = match account { + 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)) } } @@ -139,91 +156,121 @@ impl Cmd { mod tests { use super::*; - // Test vectors from SEP-53 + // Public key = GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L const TEST_PUBLIC_KEY: &str = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"; + const FALSE_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ"; + const FALSE_SIGNATURE: &str = + "+F//cUINZgTe4vZNXOEJTchDgEYlvy+iGFH3P65KeVhoyZgAsmGRRYAQLVqgY9J3PAlHPbSSeU5advhswmAfDg=="; + + fn setup_locator() -> locator::Args { + let temp_dir = tempfile::tempdir().unwrap(); + locator::Args { + global: false, + config_dir: Some(temp_dir.path().to_path_buf()), + } + } - // 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() + fn global_args() -> global::Args { + global::Args { + quiet: true, + ..Default::default() + } } #[test] - fn test_verify_ascii_message() { - assert!(verify_message( - TEST_MESSAGE_1.as_bytes(), - TEST_SIGNATURE_1, - TEST_PUBLIC_KEY - )); + fn test_verify_simple() { + // SEP-53 - test case 1 + let message = "Hello, World!".to_string(); + let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + + let global = global_args(); + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + signature: signature.to_string(), + public_key: TEST_PUBLIC_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let successful = cmd.run(&global); + assert!(successful.is_ok()); } #[test] - fn test_verify_utf8_message() { - assert!(verify_message( - TEST_MESSAGE_2.as_bytes(), - TEST_SIGNATURE_2, - TEST_PUBLIC_KEY - )); + fn test_verify_japanese() { + // SEP-53 - test case 2 + let message = "γ“γ‚“γ«γ‘γ―γ€δΈ–η•ŒοΌ".to_string(); + let signature = "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA=="; + + let global = global_args(); + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + signature: signature.to_string(), + public_key: TEST_PUBLIC_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let successful = cmd.run(&global); + assert!(successful.is_ok()); } #[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 - )); + fn test_verify_base64() { + // SEP-53 - test case 3 + let message = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=".to_string(); + let signature = "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ=="; + + let global = global_args(); + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: true, + signature: signature.to_string(), + public_key: TEST_PUBLIC_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let successful = cmd.run(&global); + assert!(successful.is_ok()); } #[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 - )); + fn test_verify_bad_signature_errors() { + let message = "Hello, World!".to_string(); + + let global = global_args(); + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + signature: FALSE_SIGNATURE.to_string(), + public_key: TEST_PUBLIC_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let successful = cmd.run(&global); + assert!(successful.is_err()); } #[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 - )); + fn test_verify_bad_pubkey_errors() { + let message = "Hello, World!".to_string(); + let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="; + + let global = global_args(); + let locator = setup_locator(); + let cmd = super::Cmd { + message: Some(message), + base64: false, + signature: signature.to_string(), + public_key: FALSE_PUBLIC_KEY.to_string(), + hd_path: None, + locator: locator.clone(), + }; + let successful = cmd.run(&global); + assert!(successful.is_err()); } } diff --git a/cmd/soroban-cli/src/print.rs b/cmd/soroban-cli/src/print.rs index e8c52adff..412b9fd62 100644 --- a/cmd/soroban-cli/src/print.rs +++ b/cmd/soroban-cli/src/print.rs @@ -17,18 +17,27 @@ impl Print { Print { quiet } } + /// Print message to stderr if not in quiet mode pub fn print(&self, message: T) { if !self.quiet { eprint!("{message}"); } } + /// Print message with newline to stderr if not in quiet mode. pub fn println(&self, message: T) { if !self.quiet { eprintln!("{message}"); } } + /// Print message with newline to stdout, regardless of quiet mode. + /// + /// Use for output data that may be piped or captured by other programs. + pub fn println_stdout(&self, message: T) { + println!("{message}"); + } + pub fn clear_previous_line(&self) { if !self.quiet { if cfg!(windows) { diff --git a/cmd/soroban-cli/src/signer/mod.rs b/cmd/soroban-cli/src/signer/mod.rs index c699b30b3..a55c3a73a 100644 --- a/cmd/soroban-cli/src/signer/mod.rs +++ b/cmd/soroban-cli/src/signer/mod.rs @@ -118,12 +118,12 @@ pub fn sign_soroban_authorizations( let mut signer: Option<&Signer> = None; for s in signers { - if needle == &s.get_public_key()? { + if needle == &s.get_public_key()?.0 { signer = Some(s); } } - if needle == &source_signer.get_public_key()? { + if needle == &source_signer.get_public_key()?.0 { signer = Some(source_signer); } @@ -181,7 +181,7 @@ fn sign_soroban_authorization_entry( let payload = Sha256::digest(preimage); let p: [u8; 32] = payload.as_slice().try_into()?; let signature = signer.sign_payload(p)?; - let public_key_vec = signer.get_public_key()?.to_vec(); + let public_key_vec = signer.get_public_key()?.0.to_vec(); let map = ScMap::sorted_from(vec![ ( @@ -263,15 +263,14 @@ impl Signer { } // when we implement this for ledger we'll need it to be async so we can await for the ledger's public key - pub fn get_public_key(&self) -> Result<[u8; 32], Error> { + pub fn get_public_key(&self) -> Result { match &self.kind { - SignerKind::Local(local_key) => Ok(*local_key.key.verifying_key().as_bytes()), + SignerKind::Local(local_key) => Ok(stellar_strkey::ed25519::PublicKey::from_payload( + local_key.key.verifying_key().as_bytes(), + )?), SignerKind::Ledger(_ledger) => todo!("ledger device is not implemented"), SignerKind::Lab => Err(Error::ReturningSignatureFromLab), - SignerKind::SecureStore(secure_store_entry) => { - let pk = secure_store_entry.get_public_key()?; - Ok(pk.0) - } + SignerKind::SecureStore(secure_store_entry) => secure_store_entry.get_public_key(), } }