From 871eba0fd4911dcdeea2b8c86fcf7104faac0457 Mon Sep 17 00:00:00 2001 From: Aboogeeky Date: Wed, 4 Mar 2026 15:13:34 +0100 Subject: [PATCH 1/3] feat: integrate stellar-strkey for key validation (closes #7) --- Cargo.toml | 12 +-- src/main.rs | 3 +- src/utils/keypair.rs | 169 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 3 + 4 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 src/utils/keypair.rs create mode 100644 src/utils/mod.rs 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..0e1e1f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ pub mod friendbot; mod setup; +pub mod utils; -fn main() {} +fn main() {} \ No newline at end of file diff --git a/src/utils/keypair.rs b/src/utils/keypair.rs new file mode 100644 index 0000000..6f6a6ac --- /dev/null +++ b/src/utils/keypair.rs @@ -0,0 +1,169 @@ +//! 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::{ed25519, Strkey}; +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. +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. +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. +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()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const VALID_PUBLIC_KEY: &str = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; + const VALID_SECRET_KEY: &str = "SCZANGBA5YELQQHRKQM5AOPXJH3BZXCUAC3I7JUGWSCSM7YIZMMBZNE"; + + const VALID_SECRET_KEY_2: &str = "SBQWY3DNBE3OYYQ24X5LLPFBKR6HZPFSNHNHZE5ONXYLZGDMMJD7GSG"; + const VALID_PUBLIC_KEY_2: &str = "GDUTHCF37UX32EMANXIL2WOOVEDAALEER57DMDA4EFTLHVZ5GSREJUOG"; + + #[test] + fn valid_public_key_returns_true() { + assert!(is_valid_public_key(VALID_PUBLIC_KEY)); + } + + #[test] + fn second_valid_public_key_returns_true() { + assert!(is_valid_public_key(VALID_PUBLIC_KEY_2)); + } + + #[test] + fn secret_key_is_not_a_valid_public_key() { + assert!(!is_valid_public_key(VALID_SECRET_KEY)); + } + + #[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 truncated = &VALID_PUBLIC_KEY[..VALID_PUBLIC_KEY.len() - 1]; + assert!(!is_valid_public_key(truncated)); + } + + #[test] + fn public_key_with_wrong_prefix_is_invalid() { + let mangled = format!("X{}", &VALID_PUBLIC_KEY[1..]); + assert!(!is_valid_public_key(&mangled)); + } + + #[test] + fn valid_secret_key_returns_true() { + assert!(is_valid_secret_key(VALID_SECRET_KEY)); + } + + #[test] + fn second_valid_secret_key_returns_true() { + assert!(is_valid_secret_key(VALID_SECRET_KEY_2)); + } + + #[test] + fn public_key_is_not_a_valid_secret_key() { + assert!(!is_valid_secret_key(VALID_PUBLIC_KEY)); + } + + #[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 truncated = &VALID_SECRET_KEY[..VALID_SECRET_KEY.len() - 1]; + assert!(!is_valid_secret_key(truncated)); + } + + #[test] + fn secret_key_with_wrong_prefix_is_invalid() { + let mangled = format!("T{}", &VALID_SECRET_KEY[1..]); + assert!(!is_valid_secret_key(&mangled)); + } + + #[test] + fn derives_correct_public_key_from_secret() { + let public = public_key_from_secret(VALID_SECRET_KEY_2).unwrap(); + assert_eq!(public, VALID_PUBLIC_KEY_2); + } + + #[test] + fn derived_public_key_starts_with_g_and_is_56_chars() { + let public = public_key_from_secret(VALID_SECRET_KEY).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 err = public_key_from_secret(VALID_PUBLIC_KEY).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(_))); + } +} \ No newline at end of file diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..54219ba --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,3 @@ +//! Shared utility modules for contract-fox. + +pub mod keypair; \ No newline at end of file From e52c1b4a038ba2b24d1b209a1378df7b44c9e2ce Mon Sep 17 00:00:00 2001 From: Aboogeeky Date: Wed, 4 Mar 2026 15:33:42 +0100 Subject: [PATCH 2/3] fix: use programmatic key generation in tests and fix rustfmt issues --- src/utils/keypair.rs | 75 ++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/src/utils/keypair.rs b/src/utils/keypair.rs index 6f6a6ac..27c4f88 100644 --- a/src/utils/keypair.rs +++ b/src/utils/keypair.rs @@ -5,7 +5,7 @@ //! a secret key. use ed25519_dalek::SigningKey; -use stellar_strkey::{ed25519, Strkey}; +use stellar_strkey::{Strkey, ed25519}; use thiserror::Error; // ── Error type ──────────────────────────────────────────────────────────────── @@ -25,11 +25,15 @@ pub enum KeyError { // ── 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(_))) } @@ -37,6 +41,9 @@ pub fn is_valid_secret_key(key: &str) -> bool { // ── 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, @@ -50,31 +57,45 @@ pub fn public_key_from_secret(secret: &str) -> Result { 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::*; - const VALID_PUBLIC_KEY: &str = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"; - const VALID_SECRET_KEY: &str = "SCZANGBA5YELQQHRKQM5AOPXJH3BZXCUAC3I7JUGWSCSM7YIZMMBZNE"; - - const VALID_SECRET_KEY_2: &str = "SBQWY3DNBE3OYYQ24X5LLPFBKR6HZPFSNHNHZE5ONXYLZGDMMJD7GSG"; - const VALID_PUBLIC_KEY_2: &str = "GDUTHCF37UX32EMANXIL2WOOVEDAALEER57DMDA4EFTLHVZ5GSREJUOG"; + // ── is_valid_public_key ─────────────────────────────────────────────────── #[test] fn valid_public_key_returns_true() { - assert!(is_valid_public_key(VALID_PUBLIC_KEY)); + let (_, public) = make_keypair(1); + assert!(is_valid_public_key(&public)); } #[test] fn second_valid_public_key_returns_true() { - assert!(is_valid_public_key(VALID_PUBLIC_KEY_2)); + let (_, public) = make_keypair(2); + assert!(is_valid_public_key(&public)); } #[test] fn secret_key_is_not_a_valid_public_key() { - assert!(!is_valid_public_key(VALID_SECRET_KEY)); + let (secret, _) = make_keypair(1); + assert!(!is_valid_public_key(&secret)); } #[test] @@ -89,29 +110,36 @@ mod tests { #[test] fn truncated_public_key_is_invalid() { - let truncated = &VALID_PUBLIC_KEY[..VALID_PUBLIC_KEY.len() - 1]; + 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 mangled = format!("X{}", &VALID_PUBLIC_KEY[1..]); + 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() { - assert!(is_valid_secret_key(VALID_SECRET_KEY)); + let (secret, _) = make_keypair(1); + assert!(is_valid_secret_key(&secret)); } #[test] fn second_valid_secret_key_returns_true() { - assert!(is_valid_secret_key(VALID_SECRET_KEY_2)); + let (secret, _) = make_keypair(2); + assert!(is_valid_secret_key(&secret)); } #[test] fn public_key_is_not_a_valid_secret_key() { - assert!(!is_valid_secret_key(VALID_PUBLIC_KEY)); + let (_, public) = make_keypair(1); + assert!(!is_valid_secret_key(&public)); } #[test] @@ -126,25 +154,31 @@ mod tests { #[test] fn truncated_secret_key_is_invalid() { - let truncated = &VALID_SECRET_KEY[..VALID_SECRET_KEY.len() - 1]; + 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 mangled = format!("T{}", &VALID_SECRET_KEY[1..]); + 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 public = public_key_from_secret(VALID_SECRET_KEY_2).unwrap(); - assert_eq!(public, VALID_PUBLIC_KEY_2); + 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 public = public_key_from_secret(VALID_SECRET_KEY).unwrap(); + let (secret, _) = make_keypair(1); + let public = public_key_from_secret(&secret).unwrap(); assert!(public.starts_with('G')); assert_eq!(public.len(), 56); } @@ -157,7 +191,8 @@ mod tests { #[test] fn public_key_from_public_key_returns_err() { - let err = public_key_from_secret(VALID_PUBLIC_KEY).unwrap_err(); + let (_, public) = make_keypair(1); + let err = public_key_from_secret(&public).unwrap_err(); assert!(matches!(err, KeyError::InvalidSecretKey(_))); } From 27ea1989de0dde1f162f34709f1d9f025e9254d5 Mon Sep 17 00:00:00 2001 From: Aboogeeky Date: Wed, 4 Mar 2026 15:42:10 +0100 Subject: [PATCH 3/3] fix: add trailing newlines for rustfmt compliance --- src/main.rs | 2 +- src/utils/keypair.rs | 2 +- src/utils/mod.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0e1e1f0..f6c4fc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,4 +2,4 @@ pub mod friendbot; mod setup; pub mod utils; -fn main() {} \ No newline at end of file +fn main() {} diff --git a/src/utils/keypair.rs b/src/utils/keypair.rs index 27c4f88..cccf03c 100644 --- a/src/utils/keypair.rs +++ b/src/utils/keypair.rs @@ -201,4 +201,4 @@ mod tests { let err = public_key_from_secret("").unwrap_err(); assert!(matches!(err, KeyError::InvalidSecretKey(_))); } -} \ No newline at end of file +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 54219ba..5da7f66 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,3 @@ //! Shared utility modules for contract-fox. -pub mod keypair; \ No newline at end of file +pub mod keypair;