Skip to main content

ferrox_security/
lib.rs

1//! # Ferrox Security (`ferrox-security`)
2//!
3//! `ferrox-security` provides zero-trust authentication mechanisms, including PASETO (Platform-Agnostic Security Tokens) v4 local/public token generation,
4//! dual-token refresh rotation, password hashing abstractions, and authorization claim extractors.
5//!
6//! ## Why PASETO over JWT?
7//! Traditional JSON Web Tokens (JWT) suffer from algorithm confusion attacks (e.g., `none` algorithm vulnerability, RSA vs HMAC confusion).
8//! PASETO eliminates algorithm negotiation entirely by hardcoding modern cryptographic primitives (Ed25519, XChaCha20-Poly1305), making security token handling foolproof.
9//!
10//! ## Key Features
11//! - 🛡️ **PASETO v4 Support**: Encrypted local tokens and signed public tokens.
12//! - 🔑 **Token Engine**: Issue, verify, and refresh access tokens securely.
13//! - 🔒 **Password Hashing**: Secure Argon2id password hashing integration.
14
15use argon2::{
16    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
17    Argon2,
18};
19use pasetors::keys::SymmetricKey;
20use pasetors::token::UntrustedToken;
21use pasetors::version4::V4;
22use pasetors::{claims::Claims, claims::ClaimsValidationRules, Local};
23use secrecy::{ExposeSecret, Secret};
24use serde::{Deserialize, Serialize};
25use time::{Duration, OffsetDateTime};
26use ferrox_errors::AppError;
27
28pub mod auth_middleware;
29pub mod dual_token;
30pub mod public_id;
31pub mod threats;
32pub mod mtd;
33pub mod fingerprint;
34
35/// Hashes a password securely using Argon2.
36/// The input is wrapped in `Secret<String>` to guarantee it doesn't leak in logs.
37pub fn hash_password(password: Secret<String>) -> Result<String, AppError> {
38    let salt = SaltString::generate(&mut OsRng);
39    let argon2 = Argon2::default();
40
41    let password_hash = argon2
42        .hash_password(password.expose_secret().as_bytes(), &salt)
43        .map_err(|e| AppError::InternalServerError(Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))))?;
44
45    Ok(password_hash.to_string())
46}
47
48/// Verifies a password against an Argon2 hash.
49pub fn verify_password(password: Secret<String>, hash: &str) -> Result<bool, AppError> {
50    let parsed_hash = PasswordHash::new(hash)
51        .map_err(|e| AppError::ValidationError(format!("Invalid hash format: {}", e)))?;
52
53    let argon2 = Argon2::default();
54    Ok(argon2
55        .verify_password(password.expose_secret().as_bytes(), &parsed_hash)
56        .is_ok())
57}
58
59/// A securely typed PASETO Auth Engine
60pub struct PasetoAuth {
61    key: SymmetricKey<V4>,
62}
63
64#[derive(Clone, Debug, Serialize, Deserialize)]
65pub struct AuthPayload {
66    pub user_id: String,
67    pub role: String,
68}
69
70impl PasetoAuth {
71    pub fn new(secret: Secret<String>) -> Result<Self, AppError> {
72        let bytes = secret.expose_secret().as_bytes();
73        let mut key_bytes = [0u8; 32];
74        if bytes.len() >= 32 {
75            key_bytes.copy_from_slice(&bytes[..32]);
76        } else {
77            key_bytes[..bytes.len()].copy_from_slice(bytes);
78        }
79
80        let key = SymmetricKey::<V4>::from(&key_bytes)
81            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
82        Ok(Self { key })
83    }
84
85    /// Generates a local (symmetric) PASETO v4 token
86    pub fn generate_token(&self, payload: &AuthPayload, duration: Duration) -> Result<String, AppError> {
87        let mut claims = Claims::new().map_err(|e| AppError::InternalServerError(Box::new(e)))?;
88        
89        // Add expiration
90        let exp = OffsetDateTime::now_utc() + duration;
91        let exp_iso = exp.format(&time::format_description::well_known::Rfc3339)
92            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
93        claims.expiration(&exp_iso).map_err(|e| AppError::InternalServerError(Box::new(e)))?;
94
95        // Add custom payload
96        let user_id_val = serde_json::to_value(&payload.user_id)
97            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
98        claims.add_additional("user_id", user_id_val)
99            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
100            
101        let role_val = serde_json::to_value(&payload.role)
102            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
103        claims.add_additional("role", role_val)
104            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
105
106        // Encrypt and sign
107        pasetors::local::encrypt(&self.key, &claims, None, Some(b"ferrox-auth-footer"))
108            .map_err(|e| AppError::InternalServerError(Box::new(e)))
109    }
110
111    /// Validates a PASETO v4 token and returns the payload if successful
112    pub fn validate_token(&self, token: &str) -> Result<AuthPayload, AppError> {
113        let validation_rules = ClaimsValidationRules::new();
114        
115        let untrusted_token = pasetors::token::UntrustedToken::<pasetors::Local, pasetors::version4::V4>::try_from(token)
116            .map_err(|_| AppError::ValidationError("Invalid token format".to_string()))?;
117
118        let trusted_token = pasetors::local::decrypt(
119            &self.key,
120            &untrusted_token,
121            &validation_rules,
122            None,
123            Some(b"ferrox-auth-footer"),
124        ).map_err(|e| AppError::InternalServerError(Box::new(std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))))?;
125
126        let payload_json = trusted_token.payload();
127
128        let mut claims: serde_json::Value = serde_json::from_slice(payload_json.as_bytes())
129            .map_err(|e| AppError::InternalServerError(Box::new(e)))?;
130
131        let user_id = claims.get("user_id")
132            .and_then(|v| v.as_str())
133            .map(|s| s.to_string())
134            .ok_or_else(|| AppError::ValidationError("Missing user_id claim".to_string()))?;
135
136        let role = claims.get("role")
137            .and_then(|v| v.as_str())
138            .map(|s| s.to_string())
139            .ok_or_else(|| AppError::ValidationError("Missing role claim".to_string()))?;
140
141        Ok(AuthPayload { user_id, role })
142    }
143}
144
145pub fn setup() {
146    println!("ferrox-security initialized: Argon2 Hashing and PASETO Authentication ready.");
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_argon2_hashing() {
155        let password = Secret::new("SuperSecureP@ssw0rd!".to_string());
156        
157        // Hash it
158        let hash = hash_password(password.clone()).unwrap();
159        assert!(hash.starts_with("$argon2"));
160
161        // Verify with correct password
162        let is_valid = verify_password(password, &hash).unwrap();
163        assert!(is_valid);
164
165        // Verify with wrong password
166        let wrong_password = Secret::new("WrongPassword!".to_string());
167        let is_valid_wrong = verify_password(wrong_password, &hash).unwrap();
168        assert!(!is_valid_wrong);
169    }
170
171    #[test]
172    fn test_paseto_token_lifecycle() {
173        // Must be exactly 32 bytes for V4 local
174        let secret = Secret::new("12345678901234567890123456789012".to_string());
175        let auth = PasetoAuth::new(secret).unwrap();
176
177        let payload = AuthPayload {
178            user_id: "user-123".into(),
179            role: "admin".into(),
180        };
181
182        // Generate token valid for 1 hour
183        let token = auth.generate_token(&payload, Duration::hours(1)).unwrap();
184        assert!(token.starts_with("v4.local.")); // PASETO V4 local format
185
186        // Validate token
187        let validated = auth.validate_token(&token).unwrap();
188        assert_eq!(validated.user_id, "user-123");
189        assert_eq!(validated.role, "admin");
190
191        // Validate bad token
192        let bad_token = "v4.local.bad_data_here";
193        assert!(auth.validate_token(bad_token).is_err());
194    }
195}