soroban_cli/commands/message/
verify.rs

1use std::io::{self, Read};
2
3use crate::{
4    commands::global,
5    config::{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    Ed25519(#[from] ed25519_dalek::SignatureError),
34
35    #[error(transparent)]
36    Address(#[from] crate::config::address::Error),
37
38    #[error("Signature verification failed")]
39    VerificationFailed,
40
41    #[error("Invalid signature length: expected 64 bytes, got {0}")]
42    InvalidSignatureLength(usize),
43}
44
45#[derive(Debug, Parser, Clone)]
46#[group(skip)]
47pub struct Cmd {
48    /// The message to verify. If not provided, reads from stdin. This should **not** include
49    /// the SEP-53 prefix "Stellar Signed Message:\n", as it will be added automatically.
50    #[arg()]
51    pub message: Option<String>,
52
53    /// Treat the message as base64-encoded binary data
54    #[arg(long)]
55    pub base64: bool,
56
57    /// The base64-encoded signature to verify
58    #[arg(long, short = 's')]
59    pub signature: String,
60
61    /// The public key to verify the signature against. Can be an identity (--public-key alice),
62    /// a public key (--public-key GDKW...).
63    #[arg(long, short = 'p')]
64    pub public_key: String,
65
66    /// If public key identity is a seed phrase use this hd path, default is 0
67    #[arg(long)]
68    pub hd_path: Option<usize>,
69
70    #[command(flatten)]
71    pub locator: locator::Args,
72}
73
74impl Cmd {
75    pub fn run(&self, global_args: &global::Args) -> Result<(), Error> {
76        let print = Print::new(global_args.quiet);
77
78        // Create the SEP-53 payload: prefix + message as utf-8 byte array
79        let message_bytes = self.get_message_bytes()?;
80        let mut payload = Vec::with_capacity(SEP53_PREFIX.len() + message_bytes.len());
81        payload.extend_from_slice(SEP53_PREFIX.as_bytes());
82        payload.extend_from_slice(&message_bytes);
83
84        // Hash the payload with SHA-256
85        let hash: [u8; 32] = Sha256::digest(&payload).into();
86
87        // Decode the signature
88        let signature_bytes = BASE64.decode(&self.signature)?;
89        if signature_bytes.len() != 64 {
90            return Err(Error::InvalidSignatureLength(signature_bytes.len()));
91        }
92        let signature = Signature::from_slice(&signature_bytes)?;
93
94        // Get the verifying key
95        let public_key = self.get_public_key()?;
96        print.infoln(format!("Verifying signature against: {public_key}"));
97        let verifying_key = VerifyingKey::from_bytes(&public_key.0)?;
98
99        // Verify the signature
100        if verifying_key.verify(&hash, &signature).is_ok() {
101            print.checkln("Signature valid");
102            Ok(())
103        } else {
104            print.errorln("Signature invalid");
105            Err(Error::VerificationFailed)
106        }
107    }
108
109    fn get_message_bytes(&self) -> Result<Vec<u8>, Error> {
110        let message_str = if let Some(msg) = &self.message {
111            msg.clone()
112        } else {
113            // Read from stdin
114            let mut buffer = String::new();
115            io::stdin().read_to_string(&mut buffer)?;
116            // Remove trailing newline if present
117            if buffer.ends_with('\n') {
118                buffer.pop();
119                if buffer.ends_with('\r') {
120                    buffer.pop();
121                }
122            }
123            buffer
124        };
125
126        if self.base64 {
127            // Decode base64 input
128            Ok(BASE64.decode(&message_str)?)
129        } else {
130            // Use UTF-8 encoded message
131            Ok(message_str.into_bytes())
132        }
133    }
134
135    fn get_public_key(&self) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
136        // try to parse as stellar public key first
137        if let Ok(pk) = stellar_strkey::ed25519::PublicKey::from_string(&self.public_key) {
138            return Ok(pk);
139        }
140
141        // otherwise treat as identity and resolve
142        let account = self
143            .locator
144            .read_key(&self.public_key)?
145            .muxed_account(self.hd_path)
146            .map_err(crate::config::address::Error::from)?;
147        let bytes = match account {
148            soroban_sdk::xdr::MuxedAccount::Ed25519(uint256) => uint256.0,
149            soroban_sdk::xdr::MuxedAccount::MuxedEd25519(muxed_account) => muxed_account.ed25519.0,
150        };
151        Ok(stellar_strkey::ed25519::PublicKey(bytes))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    // Public key = GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L
160    const TEST_PUBLIC_KEY: &str = "GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L";
161    const FALSE_PUBLIC_KEY: &str = "GAREAZZQWHOCBJS236KIE3AWYBVFLSBK7E5UW3ICI3TCRWQKT5LNLCEZ";
162    const FALSE_SIGNATURE: &str =
163        "+F//cUINZgTe4vZNXOEJTchDgEYlvy+iGFH3P65KeVhoyZgAsmGRRYAQLVqgY9J3PAlHPbSSeU5advhswmAfDg==";
164
165    fn setup_locator() -> locator::Args {
166        let temp_dir = tempfile::tempdir().unwrap();
167        locator::Args {
168            global: false,
169            config_dir: Some(temp_dir.path().to_path_buf()),
170        }
171    }
172
173    fn global_args() -> global::Args {
174        global::Args {
175            quiet: true,
176            ..Default::default()
177        }
178    }
179
180    #[test]
181    fn test_verify_simple() {
182        // SEP-53 - test case 1
183        let message = "Hello, World!".to_string();
184        let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==";
185
186        let global = global_args();
187        let locator = setup_locator();
188        let cmd = super::Cmd {
189            message: Some(message),
190            base64: false,
191            signature: signature.to_string(),
192            public_key: TEST_PUBLIC_KEY.to_string(),
193            hd_path: None,
194            locator: locator.clone(),
195        };
196        let successful = cmd.run(&global);
197        assert!(successful.is_ok());
198    }
199
200    #[test]
201    fn test_verify_japanese() {
202        // SEP-53 - test case 2
203        let message = "こんにちは、世界!".to_string();
204        let signature = "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA==";
205
206        let global = global_args();
207        let locator = setup_locator();
208        let cmd = super::Cmd {
209            message: Some(message),
210            base64: false,
211            signature: signature.to_string(),
212            public_key: TEST_PUBLIC_KEY.to_string(),
213            hd_path: None,
214            locator: locator.clone(),
215        };
216        let successful = cmd.run(&global);
217        assert!(successful.is_ok());
218    }
219
220    #[test]
221    fn test_verify_base64() {
222        // SEP-53 - test case 3
223        let message = "2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo=".to_string();
224        let signature = "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==";
225
226        let global = global_args();
227        let locator = setup_locator();
228        let cmd = super::Cmd {
229            message: Some(message),
230            base64: true,
231            signature: signature.to_string(),
232            public_key: TEST_PUBLIC_KEY.to_string(),
233            hd_path: None,
234            locator: locator.clone(),
235        };
236        let successful = cmd.run(&global);
237        assert!(successful.is_ok());
238    }
239
240    #[test]
241    fn test_verify_bad_signature_errors() {
242        let message = "Hello, World!".to_string();
243
244        let global = global_args();
245        let locator = setup_locator();
246        let cmd = super::Cmd {
247            message: Some(message),
248            base64: false,
249            signature: FALSE_SIGNATURE.to_string(),
250            public_key: TEST_PUBLIC_KEY.to_string(),
251            hd_path: None,
252            locator: locator.clone(),
253        };
254        let successful = cmd.run(&global);
255        assert!(successful.is_err());
256    }
257
258    #[test]
259    fn test_verify_bad_pubkey_errors() {
260        let message = "Hello, World!".to_string();
261        let signature = "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA==";
262
263        let global = global_args();
264        let locator = setup_locator();
265        let cmd = super::Cmd {
266            message: Some(message),
267            base64: false,
268            signature: signature.to_string(),
269            public_key: FALSE_PUBLIC_KEY.to_string(),
270            hd_path: None,
271            locator: locator.clone(),
272        };
273        let successful = cmd.run(&global);
274        assert!(successful.is_err());
275    }
276}