Skip to main content

soroban_cli/commands/message/
sign.rs

1use std::io::{self, Read};
2
3use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
4use clap::Parser;
5use sha2::{Digest, Sha256};
6
7use crate::{
8    commands::{global, HEADING_SIGNING},
9    config::{locator, secret},
10    print::Print,
11    signer::{self, Signer},
12};
13
14use super::SEP53_PREFIX;
15
16#[derive(thiserror::Error, Debug)]
17pub enum Error {
18    #[error(transparent)]
19    Locator(#[from] locator::Error),
20
21    #[error(transparent)]
22    Secret(#[from] secret::Error),
23
24    #[error(transparent)]
25    Signer(#[from] signer::Error),
26
27    #[error(transparent)]
28    Io(#[from] io::Error),
29
30    #[error(transparent)]
31    Base64(#[from] base64::DecodeError),
32
33    #[error(transparent)]
34    StrKey(#[from] stellar_strkey::DecodeError),
35
36    #[error(transparent)]
37    Ed25519(#[from] ed25519_dalek::SignatureError),
38
39    #[error("No signing key provided. Use --sign-with-key")]
40    NoSigningKey,
41
42    #[error("Ledger signing of arbitrary messages is not yet supported")]
43    LedgerNotSupported,
44}
45
46#[derive(Debug, Parser, Clone)]
47#[group(skip)]
48pub struct Cmd {
49    /// The message to sign. If not provided, reads from stdin. This should **not** include
50    /// the SEP-53 prefix "Stellar Signed Message:\n", as it will be added automatically.
51    #[arg()]
52    pub message: Option<String>,
53
54    /// Treat the message as base64-encoded binary data
55    #[arg(long)]
56    pub base64: bool,
57
58    // @dev: Ledger and Lab don't support signing arbitrary messages yet. Once they do, use `sign_with::Args` here.
59    /// 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.
60    #[arg(
61        long,
62        env = "STELLAR_SIGN_WITH_KEY",
63        hide_env_values = true,
64        help_heading = HEADING_SIGNING
65    )]
66    pub sign_with_key: String,
67
68    #[arg(long, help_heading = HEADING_SIGNING)]
69    /// 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`
70    pub hd_path: Option<u32>,
71
72    #[command(flatten)]
73    pub locator: locator::Args,
74}
75
76impl Cmd {
77    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
78        let print = Print::new(global_args.quiet);
79
80        // Get the message bytes
81        let message_bytes = self.get_message_bytes()?;
82
83        // Get the signer
84        let key_or_name = &self.sign_with_key;
85        let secret = self
86            .locator
87            .get_secret_key_with_hd_path(key_or_name, self.hd_path)?;
88        let signer = secret.signer(self.hd_path, print.clone())?;
89        let public_key = signer.get_public_key()?;
90
91        // Encode signature as base64
92        let signature_base64 = sep_53_sign(&message_bytes, signer).await?;
93
94        print.infoln(format!("Signer: {public_key}"));
95        println!("{signature_base64}");
96        Ok(())
97    }
98
99    fn get_message_bytes(&self) -> Result<Vec<u8>, Error> {
100        let message_str = if let Some(msg) = &self.message {
101            msg.clone()
102        } else {
103            // Read from stdin
104            let mut buffer = String::new();
105            io::stdin().read_to_string(&mut buffer)?;
106            // Remove trailing newline if present
107            if buffer.ends_with('\n') {
108                buffer.pop();
109                if buffer.ends_with('\r') {
110                    buffer.pop();
111                }
112            }
113            buffer
114        };
115
116        if self.base64 {
117            // Decode base64 input
118            Ok(BASE64.decode(&message_str)?)
119        } else {
120            // Use UTF-8 encoded message
121            Ok(message_str.into_bytes())
122        }
123    }
124}
125
126/// Sign the given message bytes with the provided signer, returning the base64-encoded signature.
127///
128/// Expects the message bytes to be the raw message (without SEP-53 prefix).
129async fn sep_53_sign(message_bytes: &[u8], signer: Signer) -> Result<String, Error> {
130    // Create SEP-53 payload
131    let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len());
132    payload.extend_from_slice(SEP53_PREFIX.as_bytes());
133    payload.extend_from_slice(message_bytes);
134    let hash: [u8; 32] = Sha256::digest(&payload).into();
135
136    let signature = signer.sign_payload(hash).await?;
137
138    Ok(BASE64.encode(signature.to_bytes()))
139}
140
141#[cfg(test)]
142mod tests {
143    use std::str::FromStr;
144
145    use super::*;
146    use crate::{config::secret::Secret, utils::into_signing_key};
147
148    // Public key = GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L
149    const TEST_SECRET_KEY: &str = "SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW";
150
151    fn setup_locator() -> locator::Args {
152        let temp_dir = tempfile::tempdir().unwrap();
153        locator::Args {
154            config_dir: Some(temp_dir.path().to_path_buf()),
155        }
156    }
157
158    #[test]
159    fn test_malformed_sign_with_key_does_not_leak_value() {
160        let malformed = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon";
161        let locator = setup_locator();
162        let err = locator.get_secret_key(malformed).unwrap_err();
163        let err_msg = err.to_string();
164        assert!(
165            !err_msg.contains(malformed),
166            "error message must not contain the secret-bearing input; got: {err_msg}"
167        );
168    }
169
170    fn build_signer_for_test_key() -> Signer {
171        let secret = Secret::from_str(TEST_SECRET_KEY).unwrap();
172        let private_key = secret.private_key(None).unwrap();
173        let signing_key = into_signing_key(&private_key);
174        Signer {
175            kind: signer::SignerKind::Local(signer::LocalKey { key: signing_key }),
176            print: Print::new(true),
177        }
178    }
179
180    #[tokio::test]
181    async fn test_sign_simple() {
182        // SEP-53 - test case 1
183        let message = "Hello, World!".to_string();
184        let expected_signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==";
185
186        let locator = setup_locator();
187        let cmd = super::Cmd {
188            message: Some(message),
189            base64: false,
190            sign_with_key: TEST_SECRET_KEY.to_string(),
191            hd_path: None,
192            locator: locator.clone(),
193        };
194        let signer = build_signer_for_test_key();
195
196        let message_bytes = cmd.get_message_bytes().unwrap();
197        let signature_base64 = sep_53_sign(&message_bytes, signer).await.unwrap();
198
199        assert_eq!(signature_base64, expected_signature);
200    }
201
202    #[tokio::test]
203    async fn test_sign_japanese() {
204        // SEP-53 - test case 2
205        let message = "こんにちは、世界!".to_string();
206        let expected_signature = "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA==";
207
208        let locator = setup_locator();
209        let cmd = super::Cmd {
210            message: Some(message),
211            base64: false,
212            sign_with_key: TEST_SECRET_KEY.to_string(),
213            hd_path: None,
214            locator: locator.clone(),
215        };
216        let signer = build_signer_for_test_key();
217
218        let message_bytes = cmd.get_message_bytes().unwrap();
219        let signature_base64 = sep_53_sign(&message_bytes, signer).await.unwrap();
220
221        assert_eq!(signature_base64, expected_signature);
222    }
223
224    #[tokio::test]
225    async fn test_sign_base64() {
226        // SEP-53 - test case 3
227        let message = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=".to_string();
228        let expected_signature = "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==";
229
230        let locator = setup_locator();
231        let cmd = super::Cmd {
232            message: Some(message),
233            base64: true,
234            sign_with_key: TEST_SECRET_KEY.to_string(),
235            hd_path: None,
236            locator: locator.clone(),
237        };
238        let signer = build_signer_for_test_key();
239
240        let message_bytes = cmd.get_message_bytes().unwrap();
241        let signature_base64 = sep_53_sign(&message_bytes, signer).await.unwrap();
242
243        assert_eq!(signature_base64, expected_signature);
244    }
245
246    #[test]
247    fn test_help_does_not_expose_sign_with_key() {
248        use clap::CommandFactory;
249        let secret = "SDIY6AQQ75WMD4W46EYB7O6UYMHOCGQHLAQGQTKHDX4J2DYQCHVCQYFD";
250        std::env::set_var("STELLAR_SIGN_WITH_KEY", secret);
251        let mut cmd = Cmd::command();
252        let help_text = cmd.render_long_help().to_string();
253        std::env::remove_var("STELLAR_SIGN_WITH_KEY");
254        assert!(
255            !help_text.contains(secret),
256            "help text must not expose STELLAR_SIGN_WITH_KEY value"
257        );
258    }
259}