shield_core/
password.rs

1//! Password strength analysis and validation.
2//!
3//! Provides entropy calculation and feedback to prevent users from
4//! undermining Shield's EXPTIME security with weak passwords.
5//!
6//! # Example
7//!
8//! ```
9//! use shield_core::password::{check_password, StrengthLevel};
10//!
11//! let result = check_password("MyP@ssw0rd123!");
12//! println!("Entropy: {:.1} bits", result.entropy);
13//! println!("Level: {:?}", result.level);
14//! println!("Crack time: {}", result.crack_time_display());
15//! ```
16
17use std::collections::HashSet;
18
19/// Password strength levels.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum StrengthLevel {
22    /// < 30 bits - trivially crackable
23    Critical,
24    /// 30-50 bits - crackable in days
25    Weak,
26    /// 50-70 bits - crackable in years
27    Fair,
28    /// 70-90 bits - secure for most uses
29    Strong,
30    /// 90+ bits - highly secure
31    VeryStrong,
32}
33
34impl StrengthLevel {
35    /// Returns a human-readable description of the strength level.
36    #[must_use]
37    pub const fn description(&self) -> &'static str {
38        match self {
39            Self::Critical => "critically weak - change immediately",
40            Self::Weak => "weak - easily crackable",
41            Self::Fair => "fair - acceptable for low-value data",
42            Self::Strong => "strong - secure for most uses",
43            Self::VeryStrong => "very strong - highly secure",
44        }
45    }
46}
47
48/// Password strength analysis result.
49#[derive(Debug, Clone)]
50pub struct PasswordStrength {
51    /// Length of the password
52    pub length: usize,
53    /// Entropy in bits
54    pub entropy: f64,
55    /// Strength level
56    pub level: StrengthLevel,
57    /// Estimated crack time in seconds (with PBKDF2)
58    pub crack_time_seconds: f64,
59    /// Improvement suggestions
60    pub suggestions: Vec<String>,
61}
62
63impl PasswordStrength {
64    /// Human-readable crack time estimate.
65    #[must_use]
66    pub fn crack_time_display(&self) -> String {
67        let secs = self.crack_time_seconds;
68        if secs < 1.0 {
69            "instantly".to_string()
70        } else if secs < 60.0 {
71            format!("{secs:.0} seconds")
72        } else if secs < 3600.0 {
73            format!("{:.0} minutes", secs / 60.0)
74        } else if secs < 86400.0 {
75            format!("{:.0} hours", secs / 3600.0)
76        } else if secs < 31_536_000.0 {
77            format!("{:.0} days", secs / 86400.0)
78        } else if secs < 31_536_000.0 * 100.0 {
79            format!("{:.0} years", secs / 31_536_000.0)
80        } else if secs < 31_536_000.0 * 1_000_000.0 {
81            format!("{:.0} thousand years", secs / 31_536_000.0 / 1000.0)
82        } else if secs < 31_536_000.0 * 1e9 {
83            format!("{:.0} million years", secs / 31_536_000.0 / 1e6)
84        } else {
85            "billions of years".to_string()
86        }
87    }
88
89    /// Whether password meets minimum security threshold (50 bits).
90    #[must_use]
91    pub const fn is_acceptable(&self) -> bool {
92        self.entropy >= 50.0
93    }
94
95    /// Whether password meets recommended security threshold (72 bits).
96    #[must_use]
97    pub const fn is_recommended(&self) -> bool {
98        self.entropy >= 72.0
99    }
100}
101
102/// Common passwords to check against.
103const COMMON_PASSWORDS: &[&str] = &[
104    "password", "123456", "12345678", "qwerty", "abc123", "monkey", "master",
105    "dragon", "letmein", "login", "admin", "welcome", "shadow", "sunshine",
106    "princess", "football", "baseball", "iloveyou", "trustno1", "superman",
107    "batman", "passw0rd", "hello", "charlie", "donald", "password1",
108    "123456789", "1234567890", "1234567", "12345", "1234", "111111", "000000",
109    "qwerty123", "password123", "letmein123", "welcome1", "admin123", "root",
110];
111
112/// Calculate character set size used in password.
113fn calculate_charset_size(password: &str) -> usize {
114    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
115    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
116    let has_digit = password.chars().any(|c| c.is_ascii_digit());
117    let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric());
118
119    let mut size = 0;
120    if has_lower { size += 26; }
121    if has_upper { size += 26; }
122    if has_digit { size += 10; }
123    if has_special { size += 32; }
124
125    size.max(1)
126}
127
128/// Check if password matches common patterns.
129fn has_common_pattern(password: &str) -> bool {
130    let lower = password.to_lowercase();
131
132    // Check for repeated character
133    if password.len() > 1 {
134        let first = password.chars().next().unwrap();
135        if password.chars().all(|c| c == first) {
136            return true;
137        }
138    }
139
140    // Check for sequential digits
141    let sequential_digits = ["012", "123", "234", "345", "456", "567", "678", "789", "890"];
142    for seq in sequential_digits {
143        if lower.contains(seq) {
144            return true;
145        }
146    }
147
148    // Check for keyboard patterns
149    let keyboard_patterns = ["qwerty", "asdf", "zxcv", "qazwsx"];
150    for pattern in keyboard_patterns {
151        if lower.contains(pattern) {
152            return true;
153        }
154    }
155
156    false
157}
158
159/// Calculate password entropy in bits.
160///
161/// # Arguments
162/// * `password` - Password to analyze
163///
164/// # Returns
165/// Entropy in bits, accounting for charset and patterns
166#[must_use]
167pub fn calculate_entropy(password: &str) -> f64 {
168    if password.is_empty() {
169        return 0.0;
170    }
171
172    // Check if it's a common password
173    let lower = password.to_lowercase();
174    if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
175        return 10.0; // ~1000 guesses
176    }
177
178    let charset_size = calculate_charset_size(password);
179    // Password lengths are always small enough for exact f64 representation
180    #[allow(clippy::cast_precision_loss)]
181    let base_entropy = password.len() as f64 * (charset_size as f64).log2();
182
183    // Apply pattern penalties
184    let mut penalty = 0.0;
185    if has_common_pattern(password) {
186        penalty += 10.0;
187    }
188
189    // Penalty for repeated characters
190    let unique_chars: HashSet<char> = password.chars().collect();
191    if unique_chars.len() < password.len() / 2 {
192        penalty += 5.0;
193    }
194
195    (base_entropy - penalty).max(1.0)
196}
197
198/// Get strength level from entropy.
199#[must_use]
200pub const fn get_strength_level(entropy: f64) -> StrengthLevel {
201    if entropy < 30.0 {
202        StrengthLevel::Critical
203    } else if entropy < 50.0 {
204        StrengthLevel::Weak
205    } else if entropy < 70.0 {
206        StrengthLevel::Fair
207    } else if entropy < 90.0 {
208        StrengthLevel::Strong
209    } else {
210        StrengthLevel::VeryStrong
211    }
212}
213
214/// Estimate time to crack password via brute force.
215///
216/// Assumes 10 billion guesses/second GPU, reduced by PBKDF2 100k iterations.
217#[must_use]
218pub fn estimate_crack_time(entropy: f64) -> f64 {
219    let keyspace = 2.0_f64.powf(entropy);
220    // 10B guesses/sec GPU, but PBKDF2 100k iterations slows to ~100k/sec
221    let effective_rate: f64 = 1e10 / 100_000.0;
222    (keyspace / 2.0) / effective_rate.max(1.0)
223}
224
225/// Generate improvement suggestions based on password analysis.
226fn get_suggestions(password: &str, entropy: f64) -> Vec<String> {
227    let mut suggestions = Vec::new();
228
229    if password.len() < 12 {
230        suggestions.push(format!(
231            "Increase length to 12+ characters (currently {})",
232            password.len()
233        ));
234    }
235
236    if !password.chars().any(|c| c.is_ascii_lowercase()) {
237        suggestions.push("Add lowercase letters".to_string());
238    }
239
240    if !password.chars().any(|c| c.is_ascii_uppercase()) {
241        suggestions.push("Add uppercase letters".to_string());
242    }
243
244    if !password.chars().any(|c| c.is_ascii_digit()) {
245        suggestions.push("Add numbers".to_string());
246    }
247
248    if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
249        suggestions.push("Add special characters (!@#$%^&*)".to_string());
250    }
251
252    let lower = password.to_lowercase();
253    if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
254        suggestions.push("Avoid common passwords".to_string());
255    }
256
257    if entropy < 72.0 {
258        suggestions.push("Consider using a passphrase (5+ random words)".to_string());
259    }
260
261    suggestions
262}
263
264/// Analyze password strength and provide feedback.
265///
266/// # Arguments
267/// * `password` - Password to analyze
268///
269/// # Returns
270/// `PasswordStrength` with entropy, level, crack time, and suggestions
271///
272/// # Example
273///
274/// ```
275/// use shield_core::password::check_password;
276///
277/// let result = check_password("MySecureP@ss123");
278/// assert!(result.is_acceptable());
279/// println!("Entropy: {:.1} bits", result.entropy);
280/// ```
281#[must_use]
282pub fn check_password(password: &str) -> PasswordStrength {
283    let entropy = calculate_entropy(password);
284    let level = get_strength_level(entropy);
285    let crack_time = estimate_crack_time(entropy);
286    let suggestions = get_suggestions(password, entropy);
287
288    PasswordStrength {
289        length: password.len(),
290        entropy,
291        level,
292        crack_time_seconds: crack_time,
293        suggestions,
294    }
295}
296
297/// Check password and return warning message if weak.
298///
299/// # Arguments
300/// * `password` - Password to check
301/// * `min_entropy` - Minimum acceptable entropy (default: 50 bits)
302///
303/// # Returns
304/// Warning message if password is weak, None otherwise
305#[must_use]
306pub fn warn_if_weak(password: &str, min_entropy: f64) -> Option<String> {
307    let result = check_password(password);
308
309    if result.entropy < min_entropy {
310        let mut msg = format!(
311            "Weak password: {:.0} bits entropy (recommend 72+ bits). Crack time: {}.",
312            result.entropy,
313            result.crack_time_display()
314        );
315
316        if !result.suggestions.is_empty() {
317            use std::fmt::Write;
318            let _ = write!(
319                msg,
320                " Suggestions: {}",
321                result.suggestions.iter().take(2).cloned().collect::<Vec<_>>().join("; ")
322            );
323        }
324
325        Some(msg)
326    } else {
327        None
328    }
329}
330
331/// Quick entropy check.
332#[must_use]
333pub fn entropy(password: &str) -> f64 {
334    calculate_entropy(password)
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_empty_password() {
343        assert_eq!(calculate_entropy(""), 0.0);
344    }
345
346    #[test]
347    fn test_common_password() {
348        let result = check_password("password");
349        assert_eq!(result.level, StrengthLevel::Critical);
350        assert!(result.entropy <= 10.0);
351    }
352
353    #[test]
354    fn test_weak_password() {
355        let result = check_password("abc");
356        assert_eq!(result.level, StrengthLevel::Critical);
357    }
358
359    #[test]
360    fn test_fair_password() {
361        let result = check_password("MyPassword1");
362        assert!(matches!(result.level, StrengthLevel::Weak | StrengthLevel::Fair));
363    }
364
365    #[test]
366    fn test_strong_password() {
367        let result = check_password("MyStr0ng!P@ssw0rd#2024");
368        assert!(matches!(result.level, StrengthLevel::Strong | StrengthLevel::VeryStrong));
369    }
370
371    #[test]
372    fn test_passphrase() {
373        let result = check_password("correct-horse-battery-staple-extra");
374        assert!(result.is_acceptable());
375    }
376
377    #[test]
378    fn test_charset_detection() {
379        assert_eq!(calculate_charset_size("abc"), 26);
380        assert_eq!(calculate_charset_size("ABC"), 26);
381        assert_eq!(calculate_charset_size("aA"), 52);
382        assert_eq!(calculate_charset_size("aA1"), 62);
383        assert_eq!(calculate_charset_size("aA1!"), 94);
384    }
385
386    #[test]
387    fn test_suggestions_generated() {
388        let result = check_password("abc");
389        assert!(!result.suggestions.is_empty());
390    }
391
392    #[test]
393    fn test_warn_if_weak() {
394        assert!(warn_if_weak("password", 50.0).is_some());
395        assert!(warn_if_weak("MyStr0ng!P@ssw0rd#2024", 50.0).is_none());
396    }
397
398    #[test]
399    fn test_crack_time_display() {
400        let result = check_password("password");
401        assert!(!result.crack_time_display().is_empty());
402
403        let strong = check_password("ThisIsAVeryStrongPasswordWithLotsOfEntropy!@#$");
404        assert!(strong.crack_time_display().contains("year") || strong.crack_time_display().contains("billion"));
405    }
406
407    #[test]
408    fn test_repeated_chars_penalty() {
409        let repeated = check_password("aaaaaaaa");
410        let varied = check_password("abcdefgh");
411        assert!(repeated.entropy < varied.entropy);
412    }
413
414    #[test]
415    fn test_pattern_penalty() {
416        let pattern = check_password("qwerty123");
417        let random = check_password("xkq9m2pf");
418        // Pattern should have lower entropy despite similar charset
419        assert!(pattern.entropy < random.entropy + 15.0);
420    }
421}