Skip to main content

soroban_cli/commands/message/
verify.rs

1use std::io::{self, Read};
2
3use crate::{
4    commands::global,
5    config::{key, locator, secret},
6    print::Print,
7};
8use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
9use clap::Parser;
10use ed25519_dalek::{Signature, Verifier, VerifyingKey};
11use sha2::{Digest, Sha256};
12
13use super::SEP53_PREFIX;
14
15#[derive(thiserror::Error, Debug)]
16pub enum Error {
17    #[error(transparent)]
18    Locator(#[from] locator::Error),
19
20    #[error(transparent)]
21    Secret(#[from] secret::Error),
22
23    #[error(transparent)]
24    Io(#[from] io::Error),
25
26    #[error(transparent)]
27    Base64(#[from] base64::DecodeError),
28
29    #[error(transparent)]
30    StrKey(#[from] stellar_strkey::DecodeError),
31
32    #[error(transparent)]
33    Key(#[from] key::Error),
34
35    #[error(transparent)]
36    Ed25519(#[from] ed25519_dalek::SignatureError),
37
38    #[error(transparent)]
39    Address(#[from] crate::config::address::Error),
40
41    #[error("Signature verification failed")]
42    VerificationFailed,
43
44    #[error("Invalid signature length: expected 64 bytes, got {0}")]
45    InvalidSignatureLength(usize),
46}
47
48#[derive(Debug, Parser, Clone)]
49#[group(skip)]
50pub struct Cmd {
51    /// The message to verify. If not provided, reads from stdin. This should **not** include
52    /// the SEP-53 prefix "Stellar Signed Message:\n", as it will be added automatically.
53    #[arg()]
54    pub message: Option<String>,
55
56    /// Treat the message as base64-encoded binary data
57    #[arg(long)]
58    pub base64: bool,
59
60    /// The base64-encoded signature to verify
61    #[arg(long, short = 's')]
62    pub signature: String,
63
64    /// The public key to verify the signature against. Can be an identity (--public-key alice),
65    /// a public key (--public-key GDKW...).
66    #[arg(long, short = 'p')]
67    pub public_key: String,
68
69    /// If public key identity is a seed phrase use this hd path, default is 0
70    #[arg(long)]
71    pub hd_path: Option<u32>,
72
73    #[command(flatten)]
74    pub locator: locator::Args,
75}
76
77impl Cmd {
78    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
79        let print = Print::new(global_args.quiet);
80
81        // Create the SEP-53 payload: prefix + message as utf-8 byte array
82        let message_bytes = self.get_message_bytes()?;
83        let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len());
84        payload.extend_from_slice(SEP53_PREFIX.as_bytes());
85        payload.extend_from_slice(&message_bytes);
86
87        // Hash the payload with SHA-256
88        let hash: [u8; 32] = Sha256::digest(&payload).into();
89
90        // Decode the signature
91        let signature_bytes = BASE64.decode(&self.signature)?;
92        if signature_bytes.len() != 64 {
93            return Err(Error::InvalidSignatureLength(signature_bytes.len()));
94        }
95        let signature = Signature::from_slice(&signature_bytes)?;
96
97        // Get the verifying key
98        let public_key = self.get_public_key()?;
99        print.infoln(format!("Verifying signature against: {public_key}"));
100        let verifying_key = VerifyingKey::from_bytes(&public_key.0)?;
101
102        // Verify the signature
103        if verifying_key.verify(&hash, &signature).is_ok() {
104            print.checkln("Signature valid");
105            Ok(())
106        } else {
107            print.errorln("Signature invalid");
108            Err(Error::VerificationFailed)
109        }
110    }
111
112    fn get_message_bytes(&self) -> Result<Vec<u8>, Error> {
113        let message_str = if let Some(msg) = &self.message {
114            msg.clone()
115        } else {
116            // Read from stdin
117            let mut buffer = String::new();
118            io::stdin().read_to_string(&mut buffer)?;
119            // Remove trailing newline if present
120            if buffer.ends_with('\n') {
121                buffer.pop();
122                if buffer.ends_with('\r') {
123                    buffer.pop();
124                }
125            }
126            buffer
127        };
128
129        if self.base64 {
130            // Decode base64 input
131            Ok(BASE64.decode(&message_str)?)
132        } else {
133            // Use UTF-8 encoded message
134            Ok(message_str.into_bytes())
135        }
136    }
137
138    fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
139        // Try public-only parsing first (G... or M...); fall through to alias
140        // resolution only when the input doesn't parse as any key.
141        let key = match key::Key::parse_public_only(&self.public_key) {
142            Ok(key) => key,
143            Err(err @ key::Error::PublicKeyExpected) => return Err(Error::Key(err)),
144            Err(_) => self.locator.read_key(&self.public_key)?,
145        };
146        let account = key
147            .muxed_account(self.hd_path)
148            .map_err(crate::config::address::Error::from)?;
149        let bytes = match account {
150            soroban_sdk::xdr::MuxedAccount::Ed25519(uint256) => uint256.0,
151            soroban_sdk::xdr::MuxedAccount::MuxedEd25519(muxed_account) => muxed_account.ed25519.0,
152        };
153        Ok(stellar_strkey::ed25519::PublicKey(bytes))
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    // Public key = GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L
162    const TEST_PUBLIC_KEY: &str = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L";
163    const FALSE_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
164    const FALSE_SIGNATURE: &str =
165        "+F//cUINZgTe4vZNXOEJTchDgEYlvy+iGFH3P65KeVhoyZgAsmGRRYAQLVqgY9J3PAlHPbSSeU5advhswmAfDg==";
166
167    fn setup_locator() -> locator::Args {
168        let temp_dir = tempfile::tempdir().unwrap();
169        locator::Args {
170            config_dir: Some(temp_dir.path().to_path_buf()),
171        }
172    }
173
174    fn global_args() -> global::Args {
175        global::Args {
176            quiet: true,
177            ..Default::default()
178        }
179    }
180
181    #[test]
182    fn test_verify_simple() {
183        // SEP-53 - test case 1
184        let message = "Hello, World!".to_string();
185        let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==";
186
187        let global = global_args();
188        let locator = setup_locator();
189        let cmd = super::Cmd {
190            message: Some(message),
191            base64: false,
192            signature: signature.to_string(),
193            public_key: TEST_PUBLIC_KEY.to_string(),
194            hd_path: None,
195            locator: locator.clone(),
196        };
197        let successful = cmd.run(&global);
198        assert!(successful.is_ok());
199    }
200
201    #[test]
202    fn test_verify_japanese() {
203        // SEP-53 - test case 2
204        let message = "こんにちは、世界!".to_string();
205        let signature = "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA==";
206
207        let global = global_args();
208        let locator = setup_locator();
209        let cmd = super::Cmd {
210            message: Some(message),
211            base64: false,
212            signature: signature.to_string(),
213            public_key: TEST_PUBLIC_KEY.to_string(),
214            hd_path: None,
215            locator: locator.clone(),
216        };
217        let successful = cmd.run(&global);
218        assert!(successful.is_ok());
219    }
220
221    #[test]
222    fn test_verify_base64() {
223        // SEP-53 - test case 3
224        let message = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=".to_string();
225        let signature = "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==";
226
227        let global = global_args();
228        let locator = setup_locator();
229        let cmd = super::Cmd {
230            message: Some(message),
231            base64: true,
232            signature: signature.to_string(),
233            public_key: TEST_PUBLIC_KEY.to_string(),
234            hd_path: None,
235            locator: locator.clone(),
236        };
237        let successful = cmd.run(&global);
238        assert!(successful.is_ok());
239    }
240
241    #[test]
242    fn test_verify_bad_signature_errors() {
243        let message = "Hello, World!".to_string();
244
245        let global = global_args();
246        let locator = setup_locator();
247        let cmd = super::Cmd {
248            message: Some(message),
249            base64: false,
250            signature: FALSE_SIGNATURE.to_string(),
251            public_key: TEST_PUBLIC_KEY.to_string(),
252            hd_path: None,
253            locator: locator.clone(),
254        };
255        let successful = cmd.run(&global);
256        assert!(successful.is_err());
257    }
258
259    #[test]
260    fn test_verify_bad_pubkey_errors() {
261        let message = "Hello, World!".to_string();
262        let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==";
263
264        let global = global_args();
265        let locator = setup_locator();
266        let cmd = super::Cmd {
267            message: Some(message),
268            base64: false,
269            signature: signature.to_string(),
270            public_key: FALSE_PUBLIC_KEY.to_string(),
271            hd_path: None,
272            locator: locator.clone(),
273        };
274        let successful = cmd.run(&global);
275        assert!(successful.is_err());
276    }
277
278    #[test]
279    fn test_verify_rejects_raw_secret_key_as_public_key() {
280        let secret_key = "SBF5HLRREHMS36XZNTUSKZ6FTXDZGNXOHF4EXKUL5UCWZLPBX3NGJ4BH";
281        let cmd = super::Cmd {
282            message: Some("Hello, World!".to_string()),
283            base64: false,
284            signature: "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==".to_string(),
285            public_key: secret_key.to_string(),
286            hd_path: None,
287            locator: setup_locator(),
288        };
289        let err = cmd.run(&global_args()).unwrap_err();
290        assert!(matches!(
291            err,
292            Error::Key(crate::config::key::Error::PublicKeyExpected)
293        ));
294    }
295
296    #[test]
297    fn test_verify_rejects_raw_seed_phrase_as_public_key() {
298        let seed_phrase =
299            "depth decade power loud smile spatial sign movie judge february rate broccoli";
300        let cmd = super::Cmd {
301            message: Some("Hello, World!".to_string()),
302            base64: false,
303            signature: "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==".to_string(),
304            public_key: seed_phrase.to_string(),
305            hd_path: None,
306            locator: setup_locator(),
307        };
308        let err = cmd.run(&global_args()).unwrap_err();
309        assert!(matches!(
310            err,
311            Error::Key(crate::config::key::Error::PublicKeyExpected)
312        ));
313    }
314
315    #[test]
316    fn test_verify_rejects_secure_store_as_public_key() {
317        let cmd = super::Cmd {
318            message: Some("Hello, World!".to_string()),
319            base64: false,
320            signature: "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==".to_string(),
321            public_key: "secure_store:org.stellar.cli-alice".to_string(),
322            hd_path: None,
323            locator: setup_locator(),
324        };
325        let err = cmd.run(&global_args()).unwrap_err();
326        assert!(matches!(
327            err,
328            Error::Key(crate::config::key::Error::PublicKeyExpected)
329        ));
330    }
331}