From 40ede7a490ac19d578bc01e3b0c7df4701f450fb Mon Sep 17 00:00:00 2001 From: ghzhost Date: Thu, 3 Sep 2026 00:19:38 +0000 Subject: [PATCH] fix(validation): introduce shared stellar address validator for auth, accounts, escrow, and subscriptions (#16) --- src/main.rs | 1 + src/routes/accounts.rs | 21 ++---------- src/routes/auth.rs | 6 ++++ src/services/escrow.rs | 11 ++++--- src/services/subscription.rs | 9 +++--- src/validation.rs | 62 ++++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 src/validation.rs diff --git a/src/main.rs b/src/main.rs index 4841533..3665962 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ mod middleware; mod models; mod routes; mod services; +pub mod validation; pub use config::Config; diff --git a/src/routes/accounts.rs b/src/routes/accounts.rs index 2b45951..f2df3ec 100644 --- a/src/routes/accounts.rs +++ b/src/routes/accounts.rs @@ -1,7 +1,8 @@ use crate::{ - error::{AppError, AppResult}, + error::AppResult, middleware::auth::AuthUser, services::stellar::StellarService, + validation::validate_stellar_address, AppState, }; use axum::{ @@ -57,21 +58,3 @@ pub async fn get_balances( } }))) } - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/// Rudimentary Stellar address validation — must be 56 chars and start with G. -fn validate_stellar_address(address: &str) -> AppResult<()> { - if address.len() != 56 || !address.starts_with('G') { - return Err(AppError::BadRequest( - "Invalid Stellar address: must be a 56-character G... public key".into(), - )); - } - // Ensure it's alphanumeric (base32). - if !address.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(AppError::BadRequest( - "Invalid Stellar address: contains non-base32 characters".into(), - )); - } - Ok(()) -} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index e7cd18f..30a3cf1 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -2,6 +2,7 @@ use crate::{ error::{AppError, AppResult}, middleware::auth::issue_jwt, models::user::{AuthResponse, CreateUserRequest, LoginRequest, User, UserRow}, + validation::validate_stellar_address, AppState, }; use axum::{extract::State, Json}; @@ -31,6 +32,11 @@ pub async fn register( return Err(AppError::Validation("full_name is required".into())); } + // Validate optional stellar address if provided. + if let Some(ref addr) = req.stellar_address { + validate_stellar_address(addr)?; + } + // Check uniqueness. let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)") .bind(&email) diff --git a/src/services/escrow.rs b/src/services/escrow.rs index 679dd48..fed3b99 100644 --- a/src/services/escrow.rs +++ b/src/services/escrow.rs @@ -10,6 +10,7 @@ use crate::{ stellar::StellarService, transaction::TransactionService, }, + validation::validate_stellar_address, }; use chrono::Utc; use sqlx::PgPool; @@ -58,11 +59,13 @@ impl EscrowService { if amount <= 0.0 { return Err(AppError::Validation("amount must be positive".into())); } - if req.depositor_account.trim().is_empty() || req.beneficiary_account.trim().is_empty() { - return Err(AppError::Validation( - "depositor_account and beneficiary_account are required".into(), - )); + + validate_stellar_address(&req.depositor_account)?; + validate_stellar_address(&req.beneficiary_account)?; + if let Some(ref arbiter) = req.arbiter_account { + validate_stellar_address(arbiter)?; } + if req.unlock_time <= Utc::now() { return Err(AppError::Validation( "unlock_time must be in the future".into(), diff --git a/src/services/subscription.rs b/src/services/subscription.rs index 465a519..7144225 100644 --- a/src/services/subscription.rs +++ b/src/services/subscription.rs @@ -13,6 +13,7 @@ use crate::{ stellar::StellarService, transaction::TransactionService, }, + validation::validate_stellar_address, }; use chrono::{Duration as ChronoDuration, Utc}; use sqlx::PgPool; @@ -69,11 +70,9 @@ impl SubscriptionService { "interval_seconds must be positive".into(), )); } - if req.payer_account.trim().is_empty() || req.recipient_account.trim().is_empty() { - return Err(AppError::Validation( - "payer_account and recipient_account are required".into(), - )); - } + + validate_stellar_address(&req.payer_account)?; + validate_stellar_address(&req.recipient_account)?; let next_execution_at = req .first_execution_at diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 0000000..eafff32 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,62 @@ +use crate::error::{AppError, AppResult}; + +/// Validates that a string is a canonical Stellar Ed25519 public key (`G...` address). +/// +/// Checks: +/// 1. Address is non-empty and starts with 'G' (public key) or 'M' (multiplexed). +/// 2. Address matches Stellar strkey encoding (valid Base32 and checksum). +/// +/// Uses `stellar_strkey::ed25519::PublicKey` for standard `G...` keys and +/// `stellar_strkey::ed25519::Med25519PublicKey` for `M...` multiplexed keys. +pub fn validate_stellar_address(address: &str) -> AppResult<()> { + let trimmed = address.trim(); + if trimmed.is_empty() { + return Err(AppError::Validation( + "Stellar address cannot be empty".into(), + )); + } + + if trimmed.starts_with('G') { + stellar_strkey::ed25519::PublicKey::from_string(trimmed).map_err(|_| { + AppError::Validation( + "Invalid Stellar address: must be a valid 56-character base32 Ed25519 public key".into(), + ) + })?; + Ok(()) + } else if trimmed.starts_with('M') { + stellar_strkey::ed25519::Med25519PublicKey::from_string(trimmed).map_err(|_| { + AppError::Validation( + "Invalid Stellar multiplexed address: invalid checksum or format".into(), + ) + })?; + Ok(()) + } else { + Err(AppError::Validation( + "Invalid Stellar address: must start with 'G' (or 'M' for multiplexed accounts)".into(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_stellar_address() { + // Canonical valid Stellar public key + assert!(validate_stellar_address("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI").is_ok()); + assert!(validate_stellar_address("GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN").is_ok()); + } + + #[test] + fn test_invalid_stellar_address() { + // Wrong length + assert!(validate_stellar_address("GBZXN").is_err()); + // Wrong prefix + assert!(validate_stellar_address("SBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI").is_err()); + // Invalid checksum + assert!(validate_stellar_address("GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMAD0").is_err()); + // Empty + assert!(validate_stellar_address("").is_err()); + } +}