Skip to main content

actix_security_core/http/security/
crypto.rs

1//! Password encoding utilities.
2//!
3//! # Spring Security Equivalent
4//! `org.springframework.security.crypto.password.PasswordEncoder`
5//!
6//! # Feature Flags
7//! - `argon2`: Enables `Argon2PasswordEncoder` (recommended, default)
8//! - `bcrypt`: Enables `BCryptPasswordEncoder` (widely compatible)
9
10#[cfg(feature = "argon2")]
11use argon2::password_hash::rand_core::OsRng;
12#[cfg(feature = "argon2")]
13use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
14#[cfg(feature = "argon2")]
15use argon2::Argon2;
16
17/// Trait for encoding and verifying passwords.
18///
19/// # Spring Security Equivalent
20/// `PasswordEncoder` interface
21///
22/// # Example
23/// ```ignore
24/// use actix_security_core::http::security::crypto::{PasswordEncoder, Argon2PasswordEncoder};
25///
26/// let encoder = Argon2PasswordEncoder::new();
27/// let hash = encoder.encode("my_password");
28/// assert!(encoder.matches("my_password", &hash));
29/// ```
30pub trait PasswordEncoder: Send + Sync {
31    /// Encode the raw password.
32    ///
33    /// # Spring Equivalent
34    /// `PasswordEncoder.encode(CharSequence rawPassword)`
35    fn encode(&self, raw_password: &str) -> String;
36
37    /// Verify a raw password against an encoded password.
38    ///
39    /// # Spring Equivalent
40    /// `PasswordEncoder.matches(CharSequence rawPassword, String encodedPassword)`
41    fn matches(&self, raw_password: &str, encoded_password: &str) -> bool;
42
43    /// Returns true if the encoded password should be upgraded for better security.
44    ///
45    /// # Spring Equivalent
46    /// `PasswordEncoder.upgradeEncoding(String encodedPassword)`
47    fn upgrade_encoding(&self, _encoded_password: &str) -> bool {
48        false
49    }
50}
51
52/// Argon2 password encoder - the recommended encoder for new applications.
53///
54/// # Spring Security Equivalent
55/// `Argon2PasswordEncoder`
56///
57/// Argon2 is the winner of the Password Hashing Competition and is recommended
58/// by OWASP for password storage.
59///
60/// # Feature Flag
61/// Requires the `argon2` feature (enabled by default).
62///
63/// # Example
64/// ```
65/// use actix_security_core::http::security::crypto::{PasswordEncoder, Argon2PasswordEncoder};
66///
67/// let encoder = Argon2PasswordEncoder::new();
68/// let hash = encoder.encode("secret_password");
69///
70/// // Verify correct password
71/// assert!(encoder.matches("secret_password", &hash));
72///
73/// // Verify wrong password
74/// assert!(!encoder.matches("wrong_password", &hash));
75/// ```
76#[cfg(feature = "argon2")]
77#[derive(Clone)]
78pub struct Argon2PasswordEncoder {
79    argon2: Argon2<'static>,
80}
81
82#[cfg(feature = "argon2")]
83impl Argon2PasswordEncoder {
84    /// Creates a new Argon2 password encoder with default settings.
85    pub fn new() -> Self {
86        Argon2PasswordEncoder {
87            argon2: Argon2::default(),
88        }
89    }
90}
91
92#[cfg(feature = "argon2")]
93impl Default for Argon2PasswordEncoder {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99#[cfg(feature = "argon2")]
100impl PasswordEncoder for Argon2PasswordEncoder {
101    fn encode(&self, raw_password: &str) -> String {
102        let salt = SaltString::generate(&mut OsRng);
103        self.argon2
104            .hash_password(raw_password.as_bytes(), &salt)
105            .expect("Failed to hash password")
106            .to_string()
107    }
108
109    fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
110        match PasswordHash::new(encoded_password) {
111            Ok(parsed_hash) => self
112                .argon2
113                .verify_password(raw_password.as_bytes(), &parsed_hash)
114                .is_ok(),
115            Err(_) => false,
116        }
117    }
118}
119
120/// BCrypt password encoder - widely compatible with other frameworks.
121///
122/// # Spring Security Equivalent
123/// `BCryptPasswordEncoder`
124///
125/// BCrypt is a widely-used password hashing algorithm that is compatible with
126/// many other frameworks (PHP, Node.js, etc.). Use this when migrating from
127/// other systems or for interoperability.
128///
129/// # Feature Flag
130/// Requires the `bcrypt` feature.
131///
132/// # Example
133/// ```ignore
134/// use actix_security_core::http::security::crypto::{PasswordEncoder, BCryptPasswordEncoder};
135///
136/// let encoder = BCryptPasswordEncoder::new();
137/// let hash = encoder.encode("secret_password");
138///
139/// // Verify correct password
140/// assert!(encoder.matches("secret_password", &hash));
141/// ```
142#[cfg(feature = "bcrypt")]
143#[derive(Clone)]
144pub struct BCryptPasswordEncoder {
145    cost: u32,
146}
147
148#[cfg(feature = "bcrypt")]
149impl BCryptPasswordEncoder {
150    /// Creates a new BCrypt password encoder with default cost (12).
151    pub fn new() -> Self {
152        Self { cost: 12 }
153    }
154
155    /// Creates a new BCrypt password encoder with custom cost.
156    ///
157    /// Cost should be between 4 and 31. Higher values are more secure
158    /// but slower. Default is 12.
159    pub fn with_cost(cost: u32) -> Self {
160        let cost = cost.clamp(4, 31);
161        Self { cost }
162    }
163
164    /// Create encoder with strength level.
165    ///
166    /// - `weak`: cost 10 (fast, for development)
167    /// - `default`: cost 12 (balanced)
168    /// - `strong`: cost 14 (secure, slower)
169    pub fn with_strength(strength: &str) -> Self {
170        let cost = match strength {
171            "weak" => 10,
172            "strong" => 14,
173            _ => 12,
174        };
175        Self { cost }
176    }
177}
178
179#[cfg(feature = "bcrypt")]
180impl Default for BCryptPasswordEncoder {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186#[cfg(feature = "bcrypt")]
187impl PasswordEncoder for BCryptPasswordEncoder {
188    fn encode(&self, raw_password: &str) -> String {
189        bcrypt::hash(raw_password, self.cost).expect("Failed to hash password with bcrypt")
190    }
191
192    fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
193        bcrypt::verify(raw_password, encoded_password).unwrap_or(false)
194    }
195
196    fn upgrade_encoding(&self, encoded_password: &str) -> bool {
197        // Check if the cost in the hash is lower than current setting
198        // BCrypt hashes start with $2a$, $2b$, or $2y$ followed by cost
199        if encoded_password.starts_with("$2") && encoded_password.len() > 7 {
200            // Extract cost from hash (format: $2a$XX$ where XX is cost)
201            if let Some(cost_str) = encoded_password.get(4..6) {
202                if let Ok(hash_cost) = cost_str.parse::<u32>() {
203                    return hash_cost < self.cost;
204                }
205            }
206        }
207        true // Invalid hash or unable to parse, recommend re-encoding
208    }
209}
210
211/// No-op password encoder that stores passwords in plain text.
212///
213/// # Spring Security Equivalent
214/// `NoOpPasswordEncoder`
215///
216/// # Warning
217/// **NEVER use this in production!** This is only for testing/development.
218/// Passwords are stored in plain text without any hashing.
219///
220/// # Example
221/// ```
222/// use actix_security_core::http::security::crypto::{PasswordEncoder, NoOpPasswordEncoder};
223///
224/// let encoder = NoOpPasswordEncoder;
225/// let encoded = encoder.encode("password");
226/// assert_eq!(encoded, "password"); // Plain text!
227/// assert!(encoder.matches("password", &encoded));
228/// ```
229#[derive(Clone, Copy, Default)]
230pub struct NoOpPasswordEncoder;
231
232impl PasswordEncoder for NoOpPasswordEncoder {
233    fn encode(&self, raw_password: &str) -> String {
234        raw_password.to_string()
235    }
236
237    fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
238        raw_password == encoded_password
239    }
240}
241
242/// Default encoding algorithm for DelegatingPasswordEncoder.
243#[derive(Debug, Clone, Copy, Default)]
244pub enum DefaultEncoder {
245    /// Use Argon2 (recommended)
246    #[default]
247    Argon2,
248    /// Use BCrypt (compatible)
249    BCrypt,
250}
251
252/// Delegating password encoder that supports multiple encoding formats.
253///
254/// # Spring Security Equivalent
255/// `DelegatingPasswordEncoder`
256///
257/// This encoder can verify passwords encoded with different algorithms
258/// by detecting the encoding format from a prefix in the stored hash.
259///
260/// Supported formats:
261/// - `{argon2}hash` - Argon2 encoded password
262/// - `{bcrypt}hash` - BCrypt encoded password
263/// - `{noop}plain` - Plain text (for testing only!)
264///
265/// # Feature Flag
266/// Requires the `argon2` feature (enabled by default).
267///
268/// # Example
269/// ```
270/// use actix_security_core::http::security::crypto::{PasswordEncoder, DelegatingPasswordEncoder};
271///
272/// let encoder = DelegatingPasswordEncoder::new();
273///
274/// // Encode with default (argon2)
275/// let hash = encoder.encode("password");
276/// assert!(hash.starts_with("{argon2}"));
277///
278/// // Can verify both formats
279/// assert!(encoder.matches("password", &hash));
280/// assert!(encoder.matches("plain", "{noop}plain"));
281/// ```
282#[cfg(feature = "argon2")]
283#[derive(Clone)]
284pub struct DelegatingPasswordEncoder {
285    argon2: Argon2PasswordEncoder,
286    #[cfg(feature = "bcrypt")]
287    bcrypt: BCryptPasswordEncoder,
288    default_encoder: DefaultEncoder,
289}
290
291#[cfg(feature = "argon2")]
292impl DelegatingPasswordEncoder {
293    /// Creates a new delegating password encoder.
294    /// Default encoding is Argon2.
295    pub fn new() -> Self {
296        DelegatingPasswordEncoder {
297            argon2: Argon2PasswordEncoder::new(),
298            #[cfg(feature = "bcrypt")]
299            bcrypt: BCryptPasswordEncoder::new(),
300            default_encoder: DefaultEncoder::Argon2,
301        }
302    }
303
304    /// Set the default encoder to use for new passwords.
305    pub fn default_encoder(mut self, encoder: DefaultEncoder) -> Self {
306        self.default_encoder = encoder;
307        self
308    }
309
310    /// Use BCrypt as the default encoder.
311    #[cfg(feature = "bcrypt")]
312    pub fn use_bcrypt(self) -> Self {
313        self.default_encoder(DefaultEncoder::BCrypt)
314    }
315}
316
317#[cfg(feature = "argon2")]
318impl Default for DelegatingPasswordEncoder {
319    fn default() -> Self {
320        Self::new()
321    }
322}
323
324#[cfg(feature = "argon2")]
325impl PasswordEncoder for DelegatingPasswordEncoder {
326    fn encode(&self, raw_password: &str) -> String {
327        match self.default_encoder {
328            DefaultEncoder::Argon2 => {
329                format!("{{argon2}}{}", self.argon2.encode(raw_password))
330            }
331            #[cfg(feature = "bcrypt")]
332            DefaultEncoder::BCrypt => {
333                format!("{{bcrypt}}{}", self.bcrypt.encode(raw_password))
334            }
335            #[cfg(not(feature = "bcrypt"))]
336            DefaultEncoder::BCrypt => {
337                // Fall back to argon2 if bcrypt not available
338                format!("{{argon2}}{}", self.argon2.encode(raw_password))
339            }
340        }
341    }
342
343    fn matches(&self, raw_password: &str, encoded_password: &str) -> bool {
344        if let Some(hash) = encoded_password.strip_prefix("{argon2}") {
345            self.argon2.matches(raw_password, hash)
346        } else if let Some(plain) = encoded_password.strip_prefix("{noop}") {
347            raw_password == plain
348        } else {
349            #[cfg(feature = "bcrypt")]
350            if let Some(hash) = encoded_password.strip_prefix("{bcrypt}") {
351                return self.bcrypt.matches(raw_password, hash);
352            }
353            // Legacy: try bcrypt without prefix (common in migrations)
354            #[cfg(feature = "bcrypt")]
355            if encoded_password.starts_with("$2") {
356                return self.bcrypt.matches(raw_password, encoded_password);
357            }
358            // Legacy: assume plain text for backward compatibility
359            raw_password == encoded_password
360        }
361    }
362
363    fn upgrade_encoding(&self, encoded_password: &str) -> bool {
364        // Recommend upgrade if not using the preferred encoder
365        match self.default_encoder {
366            DefaultEncoder::Argon2 => !encoded_password.starts_with("{argon2}"),
367            DefaultEncoder::BCrypt => !encoded_password.starts_with("{bcrypt}"),
368        }
369    }
370}
371
372#[cfg(all(test, feature = "argon2"))]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn test_argon2_encoder() {
378        let encoder = Argon2PasswordEncoder::new();
379        let password = "test_password_123";
380
381        let hash = encoder.encode(password);
382
383        // Hash should not equal plain password
384        assert_ne!(hash, password);
385
386        // Should verify correctly
387        assert!(encoder.matches(password, &hash));
388        assert!(!encoder.matches("wrong_password", &hash));
389    }
390
391    #[test]
392    fn test_noop_encoder() {
393        let encoder = NoOpPasswordEncoder;
394        let password = "plain_password";
395
396        let encoded = encoder.encode(password);
397        assert_eq!(encoded, password);
398        assert!(encoder.matches(password, &encoded));
399    }
400
401    #[test]
402    fn test_delegating_encoder() {
403        let encoder = DelegatingPasswordEncoder::new();
404
405        // Test argon2 encoding
406        let hash = encoder.encode("password");
407        assert!(hash.starts_with("{argon2}"));
408        assert!(encoder.matches("password", &hash));
409
410        // Test noop format
411        assert!(encoder.matches("plain", "{noop}plain"));
412
413        // Test upgrade recommendation
414        assert!(encoder.upgrade_encoding("{noop}plain"));
415        assert!(!encoder.upgrade_encoding(&hash));
416    }
417}