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",
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
145fn 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
169fn has_common_pattern(password: &str) -> bool {
171 let lower = password.to_lowercase();
172
173 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 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 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#[must_use]
210pub fn calculate_entropy(password: &str) -> f64 {
211 if password.is_empty() {
212 return 0.0;
213 }
214
215 let lower = password.to_lowercase();
217 if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
218 return 10.0; }
220
221 let charset_size = calculate_charset_size(password);
222 #[allow(clippy::cast_precision_loss)]
224 let base_entropy = password.len() as f64 * (charset_size as f64).log2();
225
226 let mut penalty = 0.0;
228 if has_common_pattern(password) {
229 penalty += 10.0;
230 }
231
232 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#[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#[must_use]
261pub fn estimate_crack_time(entropy: f64) -> f64 {
262 let keyspace = 2.0_f64.powf(entropy);
263 let effective_rate: f64 = 1e10 / 100_000.0;
265 (keyspace / 2.0) / effective_rate.max(1.0)
266}
267
268fn 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#[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#[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#[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 assert!(pattern.entropy < random.entropy + 15.0);
478 }
479}