Skip to main content

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