1use std::collections::HashSet;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum StrengthLevel {
22 Critical,
24 Weak,
26 Fair,
28 Strong,
30 VeryStrong,
32}
33
34impl StrengthLevel {
35 #[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#[derive(Debug, Clone)]
50pub struct PasswordStrength {
51 pub length: usize,
53 pub entropy: f64,
55 pub level: StrengthLevel,
57 pub crack_time_seconds: f64,
59 pub suggestions: Vec<String>,
61}
62
63impl PasswordStrength {
64 #[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 #[must_use]
91 pub const fn is_acceptable(&self) -> bool {
92 self.entropy >= 50.0
93 }
94
95 #[must_use]
97 pub const fn is_recommended(&self) -> bool {
98 self.entropy >= 72.0
99 }
100}
101
102const 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
112fn 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
128fn has_common_pattern(password: &str) -> bool {
130 let lower = password.to_lowercase();
131
132 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 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 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#[must_use]
167pub fn calculate_entropy(password: &str) -> f64 {
168 if password.is_empty() {
169 return 0.0;
170 }
171
172 let lower = password.to_lowercase();
174 if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
175 return 10.0; }
177
178 let charset_size = calculate_charset_size(password);
179 #[allow(clippy::cast_precision_loss)]
181 let base_entropy = password.len() as f64 * (charset_size as f64).log2();
182
183 let mut penalty = 0.0;
185 if has_common_pattern(password) {
186 penalty += 10.0;
187 }
188
189 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#[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#[must_use]
218pub fn estimate_crack_time(entropy: f64) -> f64 {
219 let keyspace = 2.0_f64.powf(entropy);
220 let effective_rate: f64 = 1e10 / 100_000.0;
222 (keyspace / 2.0) / effective_rate.max(1.0)
223}
224
225fn 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#[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#[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#[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 assert!(pattern.entropy < random.entropy + 15.0);
420 }
421}