diff --git a/Cargo.toml b/Cargo.toml index 03d27f8..9a67999 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] -reqwest = { version = "0.12", features = ["blocking","json"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +stellar-strkey = "0.0.8" +ed25519-dalek = "2" diff --git a/src/main.rs b/src/main.rs index ca45c4d..f6c4fc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ pub mod friendbot; mod setup; +pub mod utils; fn main() {} diff --git a/src/utils/keypair.rs b/src/utils/keypair.rs new file mode 100644 index 0000000..cccf03c --- /dev/null +++ b/src/utils/keypair.rs @@ -0,0 +1,204 @@ +//! Stellar key validation and derivation helpers. +//! +//! Wraps [`stellar_strkey`] to provide simple, boolean-returning predicates +//! for public and secret keys, plus a helper that derives a public key from +//! a secret key. + +use ed25519_dalek::SigningKey; +use stellar_strkey::{Strkey, ed25519}; +use thiserror::Error; + +// ── Error type ──────────────────────────────────────────────────────────────── + +/// Errors that can occur during key operations. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum KeyError { + /// The supplied secret key string is not a valid Stellar secret key. + #[error("invalid secret key: {0}")] + InvalidSecretKey(String), + + /// The supplied public key string is not a valid Stellar public key. + #[error("invalid public key: {0}")] + InvalidPublicKey(String), +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +/// Returns `true` if `key` is a valid Stellar public (account) key. +/// +/// A valid Stellar public key starts with `G` and is 56 characters long. +pub fn is_valid_public_key(key: &str) -> bool { + matches!(Strkey::from_string(key), Ok(Strkey::PublicKeyEd25519(_))) +} + +/// Returns `true` if `key` is a valid Stellar secret (seed) key. +/// +/// A valid Stellar secret key starts with `S` and is 56 characters long. +pub fn is_valid_secret_key(key: &str) -> bool { + matches!(Strkey::from_string(key), Ok(Strkey::PrivateKeyEd25519(_))) +} + +// ── Derivation helper ───────────────────────────────────────────────────────── + +/// Derives the Stellar public key (account ID) from a secret key string. +/// +/// Returns the public key as a StrKey-encoded `String` (starts with `G`), +/// or a [`KeyError`] if the supplied secret key is invalid. +pub fn public_key_from_secret(secret: &str) -> Result { + let seed = match Strkey::from_string(secret) { + Ok(Strkey::PrivateKeyEd25519(s)) => s, + _ => return Err(KeyError::InvalidSecretKey(secret.to_owned())), + }; + + let signing_key = SigningKey::from_bytes(&seed.0); + let verifying_key: ed25519_dalek::VerifyingKey = signing_key.verifying_key(); + + let public = ed25519::PublicKey(verifying_key.to_bytes()); + Ok(Strkey::PublicKeyEd25519(public).to_string()) +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +/// Generate a valid (secret, public) key pair string from a deterministic seed. +/// Only used in tests — the seed bytes are arbitrary but fixed. +#[cfg(test)] +fn make_keypair(seed_byte: u8) -> (String, String) { + let seed_bytes = [seed_byte; 32]; + let signing_key = SigningKey::from_bytes(&seed_bytes); + let verifying_key = signing_key.verifying_key(); + + let secret = Strkey::PrivateKeyEd25519(ed25519::PrivateKey(seed_bytes)).to_string(); + let public = Strkey::PublicKeyEd25519(ed25519::PublicKey(verifying_key.to_bytes())).to_string(); + (secret, public) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── is_valid_public_key ─────────────────────────────────────────────────── + + #[test] + fn valid_public_key_returns_true() { + let (_, public) = make_keypair(1); + assert!(is_valid_public_key(&public)); + } + + #[test] + fn second_valid_public_key_returns_true() { + let (_, public) = make_keypair(2); + assert!(is_valid_public_key(&public)); + } + + #[test] + fn secret_key_is_not_a_valid_public_key() { + let (secret, _) = make_keypair(1); + assert!(!is_valid_public_key(&secret)); + } + + #[test] + fn empty_string_is_not_a_valid_public_key() { + assert!(!is_valid_public_key("")); + } + + #[test] + fn random_string_is_not_a_valid_public_key() { + assert!(!is_valid_public_key("not-a-key")); + } + + #[test] + fn truncated_public_key_is_invalid() { + let (_, public) = make_keypair(1); + let truncated = &public[..public.len() - 1]; + assert!(!is_valid_public_key(truncated)); + } + + #[test] + fn public_key_with_wrong_prefix_is_invalid() { + let (_, public) = make_keypair(1); + let mangled = format!("X{}", &public[1..]); + assert!(!is_valid_public_key(&mangled)); + } + + // ── is_valid_secret_key ─────────────────────────────────────────────────── + + #[test] + fn valid_secret_key_returns_true() { + let (secret, _) = make_keypair(1); + assert!(is_valid_secret_key(&secret)); + } + + #[test] + fn second_valid_secret_key_returns_true() { + let (secret, _) = make_keypair(2); + assert!(is_valid_secret_key(&secret)); + } + + #[test] + fn public_key_is_not_a_valid_secret_key() { + let (_, public) = make_keypair(1); + assert!(!is_valid_secret_key(&public)); + } + + #[test] + fn empty_string_is_not_a_valid_secret_key() { + assert!(!is_valid_secret_key("")); + } + + #[test] + fn random_string_is_not_a_valid_secret_key() { + assert!(!is_valid_secret_key("not-a-key")); + } + + #[test] + fn truncated_secret_key_is_invalid() { + let (secret, _) = make_keypair(1); + let truncated = &secret[..secret.len() - 1]; + assert!(!is_valid_secret_key(truncated)); + } + + #[test] + fn secret_key_with_wrong_prefix_is_invalid() { + let (secret, _) = make_keypair(1); + let mangled = format!("T{}", &secret[1..]); + assert!(!is_valid_secret_key(&mangled)); + } + + // ── public_key_from_secret ──────────────────────────────────────────────── + + #[test] + fn derives_correct_public_key_from_secret() { + let (secret, expected_public) = make_keypair(42); + let derived = public_key_from_secret(&secret).unwrap(); + assert_eq!(derived, expected_public); + } + + #[test] + fn derived_public_key_starts_with_g_and_is_56_chars() { + let (secret, _) = make_keypair(1); + let public = public_key_from_secret(&secret).unwrap(); + assert!(public.starts_with('G')); + assert_eq!(public.len(), 56); + } + + #[test] + fn public_key_from_invalid_secret_returns_err() { + let err = public_key_from_secret("not-a-secret").unwrap_err(); + assert_eq!(err, KeyError::InvalidSecretKey("not-a-secret".to_owned())); + } + + #[test] + fn public_key_from_public_key_returns_err() { + let (_, public) = make_keypair(1); + let err = public_key_from_secret(&public).unwrap_err(); + assert!(matches!(err, KeyError::InvalidSecretKey(_))); + } + + #[test] + fn public_key_from_empty_string_returns_err() { + let err = public_key_from_secret("").unwrap_err(); + assert!(matches!(err, KeyError::InvalidSecretKey(_))); + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..5da7f66 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +//! Shared utility modules for contract-fox. + +pub mod keypair;