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 let Some(first) = password.chars().next() {
175 if password.len() > 1 && password.chars().all(|c| c == first) {
176 return true;
177 }
178 }
179
180 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 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#[must_use]
209pub fn calculate_entropy(password: &str) -> f64 {
210 if password.is_empty() {
211 return 0.0;
212 }
213
214 let lower = password.to_lowercase();
216 if COMMON_PASSWORDS.iter().any(|&p| p == lower) {
217 return 10.0; }
219
220 let charset_size = calculate_charset_size(password);
221 #[allow(clippy::cast_precision_loss)]
223 let base_entropy = password.len() as f64 * (charset_size as f64).log2();
224
225 let mut penalty = 0.0;
227 if has_common_pattern(password) {
228 penalty += 10.0;
229 }
230
231 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#[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#[must_use]
260pub fn estimate_crack_time(entropy: f64) -> f64 {
261 let keyspace = 2.0_f64.powf(entropy);
262 let effective_rate: f64 = 1e10 / 100_000.0;
264 (keyspace / 2.0) / effective_rate.max(1.0)
265}
266
267fn 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#[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#[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#[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 assert!(pattern.entropy < random.entropy + 15.0);
477 }
478}