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",
105    "123456",
106    "12345678",
107    "qwerty",
108    "abc123",
109    "monkey",
110    "master",
111    "dragon",
112    "letmein",
113    "login",
114    "admin",
115    "welcome",
116    "shadow",
117    "sunshine",
118    "princess",
119    "football",
120    "baseball",
121    "iloveyou",
122    "trustno1",
123    "superman",
124    "batman",
125    "passw0rd",
126    "hello",
127    "charlie",
128    "donald",
129    "password1",
130    "123456789",
131    "1234567890",
132    "1234567",
133    "12345",
134    "1234",
135    "111111",
136    "000000",
137    "qwerty123",
138    "password123",
139    "letmein123",
140    "welcome1",
141    "admin123",
142    "root",
143];
144
145/// Calculate character set size used in password.
146fn calculate_charset_size(password: &str) -> usize {
147    let has_lower = password.chars().any(|c| c.is_ascii_lowercase());
148    let has_upper = password.chars().any(|c| c.is_ascii_uppercase());
149    let has_digit = password.chars().any(|c| c.is_ascii_digit());
150    let has_special = password.chars().any(|c| !c.is_ascii_alphanumeric());
151
152    let mut size = 0;
153    if has_lower {
154        size += 26;
155    }
156    if has_upper {
157        size += 26;
158    }
159    if has_digit {
160        size += 10;
161    }
162    if has_special {
163        size += 32;
164    }
165
166    size.max(1)
167}
168
169/// Check if password matches common patterns.
170fn has_common_pattern(password: &str) -> bool {
171    let lower = password.to_lowercase();
172
173    // Check for repeated character
174    if password.len() > 1 {
175        let first = password.chars().next().unwrap();
176        if password.chars().all(|c| c == first) {
177            return true;
178        }
179    }
180
181    // Check for sequential digits
182    let sequential_digits = [
183        "012", "123", "234", "345", "456", "567", "678", "789", "890",
184    ];
185    for seq in sequential_digits {
186        if lower.contains(seq) {
187            return true;
188        }
189    }
190
191    // Check for keyboard patterns
192    let keyboard_patterns = ["qwerty", "asdf", "zxcv", "qazwsx"];
193    for pattern in keyboard_patterns {
194        if lower.contains(pattern) {
195            return true;
196        }
197    }
198
199    false
200}
201
202/// Calculate password entropy in bits.
203///
204/// # Arguments
205/// * `password` - Password to analyze
206///
207/// # Returns
208/// Entropy in bits, accounting for charset and patterns
209#[must_use]
210pub fn calculate_entropy(password: &str) -> f64 {
211    if password.is_empty() {
212        return 0.0;
213    }
214
215    // Check if it's a common password
216    let lower = password.to_lowercase();
217    if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
218        return 10.0; // ~1000 guesses
219    }
220
221    let charset_size = calculate_charset_size(password);
222    // Password lengths are always small enough for exact f64 representation
223    #[allow(clippy::cast_precision_loss)]
224    let base_entropy = password.len() as f64 * (charset_size as f64).log2();
225
226    // Apply pattern penalties
227    let mut penalty = 0.0;
228    if has_common_pattern(password) {
229        penalty += 10.0;
230    }
231
232    // Penalty for repeated characters
233    let unique_chars: HashSet<char> = password.chars().collect();
234    if unique_chars.len() < password.len() / 2 {
235        penalty += 5.0;
236    }
237
238    (base_entropy - penalty).max(1.0)
239}
240
241/// Get strength level from entropy.
242#[must_use]
243pub const fn get_strength_level(entropy: f64) -> StrengthLevel {
244    if entropy < 30.0 {
245        StrengthLevel::Critical
246    } else if entropy < 50.0 {
247        StrengthLevel::Weak
248    } else if entropy < 70.0 {
249        StrengthLevel::Fair
250    } else if entropy < 90.0 {
251        StrengthLevel::Strong
252    } else {
253        StrengthLevel::VeryStrong
254    }
255}
256
257/// Estimate time to crack password via brute force.
258///
259/// Assumes 10 billion guesses/second GPU, reduced by PBKDF2 100k iterations.
260#[must_use]
261pub fn estimate_crack_time(entropy: f64) -> f64 {
262    let keyspace = 2.0_f64.powf(entropy);
263    // 10B guesses/sec GPU, but PBKDF2 100k iterations slows to ~100k/sec
264    let effective_rate: f64 = 1e10 / 100_000.0;
265    (keyspace / 2.0) / effective_rate.max(1.0)
266}
267
268/// Generate improvement suggestions based on password analysis.
269fn get_suggestions(password: &str, entropy: f64) -> Vec<String> {
270    let mut suggestions = Vec::new();
271
272    if password.len() < 12 {
273        suggestions.push(format!(
274            "Increase length to 12+ characters (currently {})",
275            password.len()
276        ));
277    }
278
279    if !password.chars().any(|c| c.is_ascii_lowercase()) {
280        suggestions.push("Add lowercase letters".to_string());
281    }
282
283    if !password.chars().any(|c| c.is_ascii_uppercase()) {
284        suggestions.push("Add uppercase letters".to_string());
285    }
286
287    if !password.chars().any(|c| c.is_ascii_digit()) {
288        suggestions.push("Add numbers".to_string());
289    }
290
291    if !password.chars().any(|c| !c.is_ascii_alphanumeric()) {
292        suggestions.push("Add special characters (!@#$%^&*)".to_string());
293    }
294
295    let lower = password.to_lowercase();
296    if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
297        suggestions.push("Avoid common passwords".to_string());
298    }
299
300    if entropy < 72.0 {
301        suggestions.push("Consider using a passphrase (5+ random words)".to_string());
302    }
303
304    suggestions
305}
306
307/// Analyze password strength and provide feedback.
308///
309/// # Arguments
310/// * `password` - Password to analyze
311///
312/// # Returns
313/// `PasswordStrength` with entropy, level, crack time, and suggestions
314///
315/// # Example
316///
317/// ```
318/// use shield_core::password::check_password;
319///
320/// let result = check_password("MySecureP@ss123");
321/// assert!(result.is_acceptable());
322/// println!("Entropy: {:.1} bits", result.entropy);
323/// ```
324#[must_use]
325pub fn check_password(password: &str) -> PasswordStrength {
326    let entropy = calculate_entropy(password);
327    let level = get_strength_level(entropy);
328    let crack_time = estimate_crack_time(entropy);
329    let suggestions = get_suggestions(password, entropy);
330
331    PasswordStrength {
332        length: password.len(),
333        entropy,
334        level,
335        crack_time_seconds: crack_time,
336        suggestions,
337    }
338}
339
340/// Check password and return warning message if weak.
341///
342/// # Arguments
343/// * `password` - Password to check
344/// * `min_entropy` - Minimum acceptable entropy (default: 50 bits)
345///
346/// # Returns
347/// Warning message if password is weak, None otherwise
348#[must_use]
349pub fn warn_if_weak(password: &str, min_entropy: f64) -> Option<String> {
350    let result = check_password(password);
351
352    if result.entropy < min_entropy {
353        let mut msg = format!(
354            "Weak password: {:.0} bits entropy (recommend 72+ bits). Crack time: {}.",
355            result.entropy,
356            result.crack_time_display()
357        );
358
359        if !result.suggestions.is_empty() {
360            use std::fmt::Write;
361            let _ = write!(
362                msg,
363                " Suggestions: {}",
364                result
365                    .suggestions
366                    .iter()
367                    .take(2)
368                    .cloned()
369                    .collect::<Vec<_>>()
370                    .join("; ")
371            );
372        }
373
374        Some(msg)
375    } else {
376        None
377    }
378}
379
380/// Quick entropy check.
381#[must_use]
382pub fn entropy(password: &str) -> f64 {
383    calculate_entropy(password)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_empty_password() {
392        assert_eq!(calculate_entropy(""), 0.0);
393    }
394
395    #[test]
396    fn test_common_password() {
397        let result = check_password("password");
398        assert_eq!(result.level, StrengthLevel::Critical);
399        assert!(result.entropy <= 10.0);
400    }
401
402    #[test]
403    fn test_weak_password() {
404        let result = check_password("abc");
405        assert_eq!(result.level, StrengthLevel::Critical);
406    }
407
408    #[test]
409    fn test_fair_password() {
410        let result = check_password("MyPassword1");
411        assert!(matches!(
412            result.level,
413            StrengthLevel::Weak | StrengthLevel::Fair
414        ));
415    }
416
417    #[test]
418    fn test_strong_password() {
419        let result = check_password("MyStr0ng!P@ssw0rd#2024");
420        assert!(matches!(
421            result.level,
422            StrengthLevel::Strong | StrengthLevel::VeryStrong
423        ));
424    }
425
426    #[test]
427    fn test_passphrase() {
428        let result = check_password("correct-horse-battery-staple-extra");
429        assert!(result.is_acceptable());
430    }
431
432    #[test]
433    fn test_charset_detection() {
434        assert_eq!(calculate_charset_size("abc"), 26);
435        assert_eq!(calculate_charset_size("ABC"), 26);
436        assert_eq!(calculate_charset_size("aA"), 52);
437        assert_eq!(calculate_charset_size("aA1"), 62);
438        assert_eq!(calculate_charset_size("aA1!"), 94);
439    }
440
441    #[test]
442    fn test_suggestions_generated() {
443        let result = check_password("abc");
444        assert!(!result.suggestions.is_empty());
445    }
446
447    #[test]
448    fn test_warn_if_weak() {
449        assert!(warn_if_weak("password", 50.0).is_some());
450        assert!(warn_if_weak("MyStr0ng!P@ssw0rd#2024", 50.0).is_none());
451    }
452
453    #[test]
454    fn test_crack_time_display() {
455        let result = check_password("password");
456        assert!(!result.crack_time_display().is_empty());
457
458        let strong = check_password("ThisIsAVeryStrongPasswordWithLotsOfEntropy!@#$");
459        assert!(
460            strong.crack_time_display().contains("year")
461                || strong.crack_time_display().contains("billion")
462        );
463    }
464
465    #[test]
466    fn test_repeated_chars_penalty() {
467        let repeated = check_password("aaaaaaaa");
468        let varied = check_password("abcdefgh");
469        assert!(repeated.entropy < varied.entropy);
470    }
471
472    #[test]
473    fn test_pattern_penalty() {
474        let pattern = check_password("qwerty123");
475        let random = check_password("xkq9m2pf");
476        // Pattern should have lower entropy despite similar charset
477        assert!(pattern.entropy < random.entropy + 15.0);
478    }
479}