soroban-cli 26.1.0

Soroban CLI
Documentation
use crate::print::Print;
use ed25519_dalek::Signer;
use keyring::Entry;
use sep5::seed_phrase::SeedPhrase;
use zeroize::Zeroize;

#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[cfg(feature = "additional-libs")]
    #[error(transparent)]
    Keyring(#[from] keyring::Error),

    #[error(transparent)]
    Sep5(#[from] sep5::error::Error),

    #[error("Secure Store keys are not allowed: additional-libs feature must be enabled")]
    FeatureNotEnabled,

    #[error("An entry for '{0}' already exists in the secure store")]
    EntryAlreadyExists(String),
}

pub struct StellarEntry {
    name: String,
    #[cfg(feature = "additional-libs")]
    keyring: Entry,
}

impl StellarEntry {
    pub fn new(name: &str) -> Result<Self, Error> {
        Ok(StellarEntry {
            name: name.to_string(),
            keyring: Entry::new(name, &whoami::username())?,
        })
    }

    pub fn write(
        &self,
        seed_phrase: SeedPhrase,
        print: &Print,
        overwrite: bool,
    ) -> Result<(), Error> {
        let exists = match self.keyring.get_password() {
            Ok(_) => true,
            Err(keyring::Error::NoEntry) => false,
            Err(e) => return Err(Error::Keyring(e)),
        };
        if exists {
            if !overwrite {
                return Err(Error::EntryAlreadyExists(self.name.clone()));
            }
            print.warnln(format!(
                "Overwriting existing key in secure store: {0}",
                self.name
            ));
        } else {
            print.infoln(format!(
                "Saving a new key to your operating system's secure store: {0}",
                self.name
            ));
        }
        self.set_seed_phrase(seed_phrase)?;
        Ok(())
    }

    fn set_seed_phrase(&self, seed_phrase: SeedPhrase) -> Result<(), Error> {
        let mut data = seed_phrase.seed_phrase.into_phrase();

        self.keyring.set_password(&data)?;
        data.zeroize();
        Ok(())
    }

    pub fn delete_seed_phrase(&self, print: &Print) -> Result<(), Error> {
        match self.keyring.delete_credential() {
            Ok(()) => Ok(()),
            Err(e) => match e {
                keyring::Error::NoEntry => {
                    print.infoln("This key was already removed from the secure store.");
                    Ok(())
                }
                _ => Err(Error::Keyring(e)),
            },
        }
    }

    fn get_seed_phrase(&self) -> Result<SeedPhrase, Error> {
        Ok(self.keyring.get_password()?.parse()?)
    }

    fn use_key<T>(
        &self,
        f: impl FnOnce(ed25519_dalek::SigningKey) -> Result<T, Error>,
        hd_path: Option<u32>,
    ) -> Result<T, Error> {
        // The underlying Mnemonic type is zeroized when dropped
        let mut key_bytes: [u8; 32] = {
            self.get_seed_phrase()?
                .from_path_index(hd_path.unwrap_or_default() as usize, None)?
                .private()
                .0
        };
        let result = {
            // Use this scope to ensure the keypair is zeroized when dropped
            let keypair = ed25519_dalek::SigningKey::from_bytes(&key_bytes);
            f(keypair)?
        };
        key_bytes.zeroize();
        Ok(result)
    }

    pub fn get_public_key(
        &self,
        hd_path: Option<u32>,
    ) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
        self.use_key(
            |keypair| {
                Ok(stellar_strkey::ed25519::PublicKey(
                    *keypair.verifying_key().as_bytes(),
                ))
            },
            hd_path,
        )
    }

    pub fn sign_data(&self, data: &[u8], hd_path: Option<u32>) -> Result<Vec<u8>, Error> {
        self.use_key(
            |keypair| {
                let signature = keypair.sign(data);
                Ok(signature.to_bytes().to_vec())
            },
            hd_path,
        )
    }
}

#[cfg(feature = "additional-libs")]
#[cfg(test)]
mod test {
    use crate::print;

    use super::*;
    use keyring::{mock, set_default_credential_builder};

    #[test]
    fn test_get_password() {
        set_default_credential_builder(mock::default_credential_builder());

        let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap();
        let seed_phrase_clone = seed_phrase.clone();

        let entry = StellarEntry::new("test").unwrap();

        // set the seed phrase
        let set_seed_phrase_result = entry.set_seed_phrase(seed_phrase);
        assert!(set_seed_phrase_result.is_ok());

        // get_seed_phrase should return the same seed phrase we set
        let get_seed_phrase_result = entry.get_seed_phrase();
        assert!(get_seed_phrase_result.is_ok());
        assert_eq!(
            seed_phrase_clone.phrase(),
            get_seed_phrase_result.unwrap().phrase()
        );
    }

    #[test]
    fn test_get_public_key() {
        set_default_credential_builder(mock::default_credential_builder());

        let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap();
        let public_key = seed_phrase.from_path_index(0, None).unwrap().public().0;

        let entry = StellarEntry::new("test").unwrap();

        // set the seed_phrase
        let set_seed_phrase_result = entry.set_seed_phrase(seed_phrase);
        assert!(set_seed_phrase_result.is_ok());

        // confirm that we can get the public key from the entry and that it matches the one we set
        let get_public_key_result = entry.get_public_key(None);
        assert!(get_public_key_result.is_ok());
        assert_eq!(public_key, get_public_key_result.unwrap().0);
    }

    #[test]
    fn test_sign_data() {
        set_default_credential_builder(mock::default_credential_builder());

        //create a seed phrase
        let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap();

        // create a keyring entry and set the seed_phrase
        let entry = StellarEntry::new("test").unwrap();
        entry.set_seed_phrase(seed_phrase).unwrap();

        let tx_xdr = r"AAAAAgAAAADh6eOnZEq1xQgKioffuH7/8D8x8+OdGFEkiYC6QKMWzQAAAGQAAACuAAAAAQAAAAAAAAAAAAAAAQAAAAAAAAAYAAAAAQAAAAAAAAAAAAAAAOHp46dkSrXFCAqKh9+4fv/wPzHz450YUSSJgLpAoxbNoFT1s8jZPCv9IJ2DsqGTA8pOtavv58JF53aDycpRPcEAAAAA+N2m5zc3EfWUmLvigYPOHKXhSy8OrWfVibc6y6PrQoYAAAAAAAAAAAAAAAA";

        let sign_tx_env_result = entry.sign_data(tx_xdr.as_bytes(), None);
        assert!(sign_tx_env_result.is_ok());
    }

    #[test]
    fn write_with_overwrite_updates_existing_entry() {
        set_default_credential_builder(mock::default_credential_builder());

        let seed_phrase_1 =
            crate::config::secret::seed_phrase_from_seed(Some("0123456789abcdef")).unwrap();
        let seed_phrase_2 =
            crate::config::secret::seed_phrase_from_seed(Some("fedcba9876543210")).unwrap();

        let pubkey_1 = seed_phrase_1.from_path_index(0, None).unwrap().public().0;
        let pubkey_2 = seed_phrase_2.from_path_index(0, None).unwrap().public().0;
        assert_ne!(pubkey_1, pubkey_2, "test seeds must produce different keys");

        let entry = StellarEntry::new("test-overwrite").unwrap();
        let print = print::Print::new(true);

        entry.write(seed_phrase_1, &print, false).unwrap();
        assert_eq!(entry.get_public_key(None).unwrap().0, pubkey_1);

        // overwrite=true must replace the entry with the new seed phrase
        entry.write(seed_phrase_2, &print, true).unwrap();
        assert_eq!(
            entry.get_public_key(None).unwrap().0,
            pubkey_2,
            "overwrite should have replaced the keyring entry"
        );
    }

    #[test]
    fn write_without_overwrite_errors_on_existing_entry() {
        set_default_credential_builder(mock::default_credential_builder());

        let seed_phrase_1 =
            crate::config::secret::seed_phrase_from_seed(Some("0123456789abcdef")).unwrap();
        let seed_phrase_2 =
            crate::config::secret::seed_phrase_from_seed(Some("fedcba9876543210")).unwrap();

        let entry = StellarEntry::new("test-no-overwrite").unwrap();
        let print = print::Print::new(true);

        entry.write(seed_phrase_1, &print, false).unwrap();

        // overwrite=false must fail with EntryAlreadyExists when an entry already exists
        let err = entry
            .write(seed_phrase_2, &print, false)
            .expect_err("write without overwrite should fail on existing entry");
        assert!(
            matches!(err, Error::EntryAlreadyExists(_)),
            "expected EntryAlreadyExists, got {err:?}"
        );
    }

    #[test]
    fn test_delete_seed_phrase() {
        set_default_credential_builder(mock::default_credential_builder());

        //create a seed phrase
        let seed_phrase = crate::config::secret::seed_phrase_from_seed(None).unwrap();

        // create a keyring entry and set the seed_phrase
        let entry = StellarEntry::new("test").unwrap();
        entry.set_seed_phrase(seed_phrase).unwrap();

        // assert it is there
        let get_seed_phrase_result = entry.get_seed_phrase();
        assert!(get_seed_phrase_result.is_ok());

        // delete the password
        let print = print::Print::new(true);
        let delete_seed_phrase_result = entry.delete_seed_phrase(&print);
        assert!(delete_seed_phrase_result.is_ok());

        // confirm the entry is gone
        let get_password_result = entry.get_seed_phrase();
        assert!(get_password_result.is_err());
        assert!(matches!(
            get_password_result.unwrap_err(),
            Error::Keyring(_)
        ));
    }
}