use chrono::{Duration, Utc};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use rand::{rngs::OsRng, Rng};
use crate::config::SolanaConfig;
use crate::errors::AppError;
use crate::models::ChallengeResponse;
#[derive(Clone)]
pub struct SolanaService {
challenge_expiry_seconds: u64,
app_name: String,
}
impl SolanaService {
const DEFAULT_APP_NAME: &'static str = "Cedros Login";
const NONCE_MARKER: &'static str = ". Nonce: ";
const TIMESTAMP_MARKER: &'static str = ". Timestamp: ";
const MESSAGE_PREFIX: &'static str = "Login to ";
pub fn new(config: &SolanaConfig, app_name: String) -> Self {
let app_name =
if app_name.contains(Self::NONCE_MARKER) || app_name.contains(Self::TIMESTAMP_MARKER) {
tracing::error!(
app_name = %app_name,
"Invalid Solana app name contains reserved markers; using default"
);
Self::DEFAULT_APP_NAME.to_string()
} else {
app_name
};
Self {
challenge_expiry_seconds: config.challenge_expiry_seconds,
app_name,
}
}
pub fn generate_challenge(&self, public_key: &str) -> Result<ChallengeResponse, AppError> {
let nonce: String = OsRng
.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let now = Utc::now();
let expires_at = now + Duration::seconds(self.challenge_expiry_seconds as i64);
let message = format!(
"Login to {} with wallet {}. Nonce: {}. Timestamp: {}.",
self.app_name,
public_key,
nonce,
now.to_rfc3339()
);
Ok(ChallengeResponse {
nonce,
message,
expires_at,
})
}
pub fn verify_signature(
&self,
public_key_base58: &str,
signature_base64: &str,
message: &str,
) -> Result<bool, AppError> {
let public_key_bytes = bs58::decode(public_key_base58)
.into_vec()
.map_err(|_| AppError::Validation("Invalid public key format".into()))?;
if public_key_bytes.len() != 32 {
return Err(AppError::Validation("Invalid public key length".into()));
}
let public_key_array: [u8; 32] = public_key_bytes
.try_into()
.map_err(|_| AppError::Validation("Invalid public key length".into()))?;
let verifying_key = VerifyingKey::from_bytes(&public_key_array)
.map_err(|_| AppError::Validation("Invalid public key".into()))?;
let signature_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_base64)
.map_err(|_| AppError::Validation("Invalid signature format".into()))?;
if signature_bytes.len() != 64 {
return Err(AppError::Validation("Invalid signature length".into()));
}
let signature_array: [u8; 64] = signature_bytes
.try_into()
.map_err(|_| AppError::Validation("Invalid signature length".into()))?;
let signature = Signature::from_bytes(&signature_array);
Ok(verifying_key.verify(message.as_bytes(), &signature).is_ok())
}
pub fn extract_nonce(message: &str) -> Option<String> {
if !message.starts_with(Self::MESSAGE_PREFIX) {
return None;
}
if message.matches(Self::NONCE_MARKER).count() != 1 {
return None;
}
let nonce_start = message.find(Self::NONCE_MARKER)?;
let after_marker = &message[nonce_start + Self::NONCE_MARKER.len()..];
let nonce_end = after_marker.find(Self::TIMESTAMP_MARKER)?;
let nonce = &after_marker[..nonce_end];
if nonce.len() != 32 || !nonce.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
if !message.ends_with('.') {
return None;
}
Some(nonce.to_string())
}
pub fn challenge_expiry_seconds(&self) -> u64 {
self.challenge_expiry_seconds
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> SolanaConfig {
SolanaConfig {
enabled: true,
challenge_expiry_seconds: 300,
}
}
#[test]
fn test_generate_challenge() {
let service = SolanaService::new(&test_config(), "TestApp".to_string());
let public_key = "test_pubkey";
let challenge = service.generate_challenge(public_key).unwrap();
assert!(!challenge.nonce.is_empty());
assert!(challenge.message.contains("Login to TestApp"));
assert!(challenge.message.contains(&challenge.nonce));
assert!(
challenge.message.contains(public_key),
"Challenge message must contain public key for binding"
);
}
#[test]
fn test_extract_nonce_from_generated_message() {
let service = SolanaService::new(&test_config(), "TestApp".to_string());
let challenge = service.generate_challenge("test_pubkey").unwrap();
let extracted = SolanaService::extract_nonce(&challenge.message);
assert_eq!(extracted, Some(challenge.nonce));
}
#[test]
fn test_invalid_app_name_rejected() {
let service = SolanaService::new(&test_config(), "Bad. Nonce: ".to_string());
let challenge = service.generate_challenge("test_pubkey").unwrap();
assert!(challenge.message.starts_with("Login to Cedros Login"));
}
#[test]
fn test_extract_nonce_rejects_invalid_length() {
let message = "Login to TestApp. Nonce: tooshort. Timestamp: 2024-01-01T00:00:00+00:00.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_extract_nonce_rejects_invalid_chars() {
let message =
"Login to TestApp. Nonce: abc123!@#$%^&*()_+def456ghi012. Timestamp: 2024-01-01T00:00:00+00:00.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_invalid_public_key() {
let service = SolanaService::new(&test_config(), "TestApp".to_string());
let result = service.verify_signature("invalid", "sig", "message");
assert!(result.is_err());
}
#[test]
fn test_extract_nonce_rejects_wrong_prefix() {
let message =
"Evil to TestApp. Nonce: 12345678901234567890123456789012. Timestamp: 2024-01-01T00:00:00+00:00.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_extract_nonce_rejects_missing_suffix() {
let message =
"Login to TestApp. Nonce: 12345678901234567890123456789012. Timestamp: 2024-01-01T00:00:00+00:00";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_app_name_injection_prevented() {
let evil_app_name = "Evil. Nonce: FAKE12345678901234567890123456. Timestamp: x";
let service = SolanaService::new(&test_config(), evil_app_name.to_string());
let challenge = service.generate_challenge("test_pubkey").unwrap();
assert!(challenge.message.starts_with("Login to Cedros Login"));
}
#[test]
fn test_extract_nonce_rejects_multiple_markers() {
let message =
"Login to TestApp. Nonce: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA. Nonce: 12345678901234567890123456789012. Timestamp: 2024-01-01T00:00:00+00:00.";
assert!(SolanaService::extract_nonce(message).is_none());
}
}