From bdb42d278648f2da756d01dec8d16c8df4c03e9d Mon Sep 17 00:00:00 2001 From: Randolf J <19544887-randolf@users.noreply.gitlab.com> Date: Sat, 11 May 2024 00:23:47 -0700 Subject: [PATCH] Make zxcvbn infallible --- Cargo.toml | 16 +++-- README.md | 8 +-- src/feedback.rs | 6 +- src/lib.rs | 140 ++++++++++++++++++++++++------------------ src/matching/mod.rs | 4 +- src/scoring.rs | 20 +++--- src/time_estimates.rs | 2 +- 7 files changed, 109 insertions(+), 87 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9965217..0d7d184 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,16 +14,17 @@ rust-version = "1.63" maintenance = { status = "passively-maintained" } [dependencies] -derive_builder = { version = "0.12.0", optional = true } -fancy-regex = "0.11.0" -itertools = "0.10.0" +derive_builder = { version = "0.20", optional = true } +fancy-regex = "0.13" +itertools = "0.12" lazy_static = "1.3" -quick-error = "2.0" regex = "1" time = { version = "0.3" } [target.'cfg(target_arch = "wasm32")'.dependencies] -js-sys = "0.3.56" +getrandom = { version = "0.2", features = ["js"] } +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["Performance"] } [dependencies.serde] optional = true @@ -36,9 +37,12 @@ version = "1" [dev-dependencies] quickcheck = "1.0.0" serde_json = "1" -criterion = "0.4" + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +criterion = "0.5" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] +criterion = { version = "0.5", default-features = false } wasm-bindgen-test = "0.3" [features] diff --git a/README.md b/README.md index 69a503a..d0dab8a 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ Consider using zxcvbn as an algorithmic alternative to password composition policy — it is more secure, flexible, and usable when sites require a minimal complexity score in place of annoying rules like "passwords must contain three of {lower, upper, numbers, symbols}". -* __More secure__: policies often fail both ways, allowing weak passwords (`P@ssword1`) and disallowing strong passwords. -* __More flexible__: zxcvbn allows many password styles to flourish so long as it detects sufficient complexity — passphrases are rated highly given enough uncommon words, keyboard patterns are ranked based on length and number of turns, and capitalization adds more complexity when it's unpredictable. -* __More usable__: zxcvbn is designed to power simple, rule-free interfaces that give instant feedback. In addition to strength estimation, zxcvbn includes minimal, targeted verbal feedback that can help guide users towards less guessable passwords. +- **More secure**: policies often fail both ways, allowing weak passwords (`P@ssword1`) and disallowing strong passwords. +- **More flexible**: zxcvbn allows many password styles to flourish so long as it detects sufficient complexity — passphrases are rated highly given enough uncommon words, keyboard patterns are ranked based on length and number of turns, and capitalization adds more complexity when it's unpredictable. +- **More usable**: zxcvbn is designed to power simple, rule-free interfaces that give instant feedback. In addition to strength estimation, zxcvbn includes minimal, targeted verbal feedback that can help guide users towards less guessable passwords. ## Installing @@ -46,7 +46,7 @@ extern crate zxcvbn; use zxcvbn::zxcvbn; fn main() { - let estimate = zxcvbn("correcthorsebatterystaple", &[]).unwrap(); + let estimate = zxcvbn("correcthorsebatterystaple", &[]); println!("{}", estimate.score()); // 3 } ``` diff --git a/src/feedback.rs b/src/feedback.rs index 0d0c28a..26603a1 100644 --- a/src/feedback.rs +++ b/src/feedback.rs @@ -301,21 +301,21 @@ mod tests { use crate::zxcvbn; let password = "password"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!( entropy.feedback.unwrap().warning, Some(Warning::ThisIsATop10Password) ); let password = "test"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!( entropy.feedback.unwrap().warning, Some(Warning::ThisIsATop100Password) ); let password = "p4ssw0rd"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!( entropy.feedback.unwrap().warning, Some(Warning::ThisIsSimilarToACommonlyUsedPassword) diff --git a/src/lib.rs b/src/lib.rs index 43426a6..20121e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,6 @@ extern crate derive_builder; #[macro_use] extern crate lazy_static; -#[macro_use] -extern crate quick_error; #[cfg(feature = "ser")] extern crate serde; @@ -23,6 +21,10 @@ use std::time::Duration; #[macro_use] extern crate quickcheck; +use time_estimates::CrackTimes; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::wasm_bindgen; + pub use crate::matching::Match; mod adjacency_graphs; @@ -33,6 +35,36 @@ pub mod matching; mod scoring; pub mod time_estimates; +#[cfg(not(target_arch = "wasm32"))] +fn time_scoped(f: F) -> (R, Duration) +where + F: FnOnce() -> R, +{ + let start_time = std::time::Instant::now(); + let result = f(); + let calc_time = std::time::Instant::now().duration_since(start_time); + (result, calc_time) +} + +#[cfg(target_arch = "wasm32")] +#[allow(non_upper_case_globals)] +fn time_scoped(f: F) -> (R, Duration) +where + F: FnOnce() -> R, +{ + #[wasm_bindgen] + extern "C" { + #[no_mangle] + #[used] + static performance: web_sys::Performance; + } + + let start_time = performance.now(); + let result = f(); + let calc_time = std::time::Duration::from_secs_f64((performance.now() - start_time) / 1000.0); + (result, calc_time) +} + /// Contains the results of an entropy calculation #[derive(Debug, Clone)] #[cfg_attr(feature = "ser", derive(Serialize))] @@ -92,63 +124,40 @@ impl Entropy { } } -quick_error! { - #[derive(Debug, Clone, Copy)] - /// Potential errors that may be returned from `zxcvbn` - pub enum ZxcvbnError { - /// Indicates that a blank password was passed in to `zxcvbn` - BlankPassword { - display("Zxcvbn cannot evaluate a blank password") - } - /// Indicates an error converting Duration to/from the standard library implementation - DurationOutOfRange { - display("Zxcvbn calculation time created a duration out of range") - } - } -} - -#[cfg(target_arch = "wasm32")] -fn duration_since_epoch() -> Result { - match js_sys::Date::new_0().get_time() as u64 { - u64::MIN | u64::MAX => Err(ZxcvbnError::DurationOutOfRange), - millis => Ok(Duration::from_millis(millis)), - } -} - -#[cfg(not(target_arch = "wasm32"))] -fn duration_since_epoch() -> Result { - std::time::SystemTime::now() - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .map_err(|_| ZxcvbnError::DurationOutOfRange) -} - /// Takes a password string and optionally a list of user-supplied inputs /// (e.g. username, email, first name) and calculates the strength of the password /// based on entropy, using a number of different factors. -pub fn zxcvbn(password: &str, user_inputs: &[&str]) -> Result { +pub fn zxcvbn(password: &str, user_inputs: &[&str]) -> Entropy { if password.is_empty() { - return Err(ZxcvbnError::BlankPassword); + return Entropy { + guesses: 0, + guesses_log10: f64::NEG_INFINITY, + crack_times: CrackTimes::new(0), + score: 0, + feedback: feedback::get_feedback(0, &[]), + sequence: Vec::default(), + calc_time: Duration::from_secs(0), + }; } - let start_time = duration_since_epoch()?; + let (result, calc_time) = time_scoped(|| { + // Only evaluate the first 100 characters of the input. + // This prevents potential DoS attacks from sending extremely long input strings. + let password = password.chars().take(100).collect::(); - // Only evaluate the first 100 characters of the input. - // This prevents potential DoS attacks from sending extremely long input strings. - let password = password.chars().take(100).collect::(); + let sanitized_inputs = user_inputs + .iter() + .enumerate() + .map(|(i, x)| (x.to_lowercase(), i + 1)) + .collect(); - let sanitized_inputs = user_inputs - .iter() - .enumerate() - .map(|(i, x)| (x.to_lowercase(), i + 1)) - .collect(); - - let matches = matching::omnimatch(&password, &sanitized_inputs); - let result = scoring::most_guessable_match_sequence(&password, &matches, false); - let calc_time = duration_since_epoch()? - start_time; + let matches = matching::omnimatch(&password, &sanitized_inputs); + scoring::most_guessable_match_sequence(&password, &matches, false) + }); let (crack_times, score) = time_estimates::estimate_attack_times(result.guesses); let feedback = feedback::get_feedback(score, &result.sequence); - Ok(Entropy { + Entropy { guesses: result.guesses, guesses_log10: result.guesses_log10, crack_times, @@ -156,12 +165,13 @@ pub fn zxcvbn(password: &str, user_inputs: &[&str]) -> Result) -> TestResult { let inputs = user_inputs.iter().map(|s| s.as_ref()).collect::>(); - zxcvbn(&password, &inputs).ok(); + zxcvbn(&password, &inputs); TestResult::from_bool(true) } #[cfg(feature = "ser")] fn test_zxcvbn_serialisation_doesnt_panic(password: String, user_inputs: Vec) -> TestResult { let inputs = user_inputs.iter().map(|s| s.as_ref()).collect::>(); - serde_json::to_string(&zxcvbn(&password, &inputs).ok()).ok(); + serde_json::to_string(&zxcvbn(&password, &inputs)).ok(); TestResult::from_bool(true) } } @@ -186,7 +196,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_zxcvbn() { let password = "r0sebudmaelstrom11/20/91aaaa"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.guesses_log10 as u16, 14); assert_eq!(entropy.score, 4); assert!(!entropy.sequence.is_empty()); @@ -194,11 +204,23 @@ mod tests { assert!(entropy.calc_time.as_nanos() > 0); } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] + fn test_zxcvbn_empty() { + let password = ""; + let entropy = zxcvbn(password, &[]); + assert_eq!(entropy.score, 0); + assert_eq!(entropy.guesses, 0); + assert_eq!(entropy.guesses_log10, f64::NEG_INFINITY); + assert_eq!(entropy.crack_times, CrackTimes::new(0)); + assert_eq!(entropy.sequence, Vec::default()); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_zxcvbn_unicode() { let password = "𐰊𐰂𐰄𐰀𐰁"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.score, 1); } @@ -206,7 +228,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_zxcvbn_unicode_2() { let password = "r0sebudmaelstrom丂/20/91aaaa"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.score, 4); } @@ -214,7 +236,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_issue_13() { let password = "Imaginative-Say-Shoulder-Dish-0"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.score, 4); } @@ -222,7 +244,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_issue_15_example_1() { let password = "TestMeNow!"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.guesses, 372_010_000); assert!((entropy.guesses_log10 - 8.57055461430783).abs() < f64::EPSILON); assert_eq!(entropy.score, 3); @@ -232,7 +254,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_issue_15_example_2() { let password = "hey<123"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.guesses, 1_010_000); assert!((entropy.guesses_log10 - 6.004321373782642).abs() < f64::EPSILON); assert_eq!(entropy.score, 2); @@ -242,7 +264,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_overflow_safety() { let password = "!QASW@#EDFR$%TGHY^&UJKI*(OL"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.guesses, u64::max_value()); assert_eq!(entropy.score, 4); } @@ -251,7 +273,7 @@ mod tests { #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test)] fn test_unicode_mb() { let password = "08märz2010"; - let entropy = zxcvbn(password, &[]).unwrap(); + let entropy = zxcvbn(password, &[]); assert_eq!(entropy.guesses, 100010000); assert_eq!(entropy.score, 3); } diff --git a/src/matching/mod.rs b/src/matching/mod.rs index cefe10f..8f90c0f 100644 --- a/src/matching/mod.rs +++ b/src/matching/mod.rs @@ -1442,9 +1442,7 @@ mod tests { #[test] fn test_date_matches_year_closest_to_reference_year() { - use time::OffsetDateTime; - - let now = OffsetDateTime::now_utc(); + let now = time::OffsetDateTime::now_utc(); let password = format!("1115{}", now.year() % 100); let matches = (matching::DateMatch {}).get_matches(&password, &HashMap::new()); let m = matches.iter().find(|m| m.token == password).unwrap(); diff --git a/src/scoring.rs b/src/scoring.rs index 6810eeb..ca76f7b 100644 --- a/src/scoring.rs +++ b/src/scoring.rs @@ -27,20 +27,19 @@ struct Optimal { g: Vec>, } -#[cfg(target_arch = "wasm32")] -fn current_year() -> i32 { - js_sys::Date::new_0().get_full_year().try_into().unwrap() -} - #[cfg(not(target_arch = "wasm32"))] -fn current_year() -> i32 { - use time::OffsetDateTime; - OffsetDateTime::now_utc().year() +lazy_static! { + pub(crate) static ref REFERENCE_YEAR: i32 = time::OffsetDateTime::now_utc().year(); } +#[cfg(target_arch = "wasm32")] lazy_static! { - pub(crate) static ref REFERENCE_YEAR: i32 = current_year(); + pub(crate) static ref REFERENCE_YEAR: i32 = web_sys::js_sys::Date::new_0() + .get_full_year() + .try_into() + .unwrap(); } + const MIN_YEAR_SPACE: i32 = 20; const BRUTEFORCE_CARDINALITY: u64 = 10; const MIN_GUESSES_BEFORE_GROWING_SEQUENCE: u64 = 10_000; @@ -809,8 +808,7 @@ mod tests { #[test] fn test_regex_guesses_current_year() { - use time::OffsetDateTime; - let token = OffsetDateTime::now_utc().year().to_string(); + let token = time::OffsetDateTime::now_utc().year().to_string(); let mut p = RegexPattern { regex_name: "recent_year", regex_match: vec![token.to_string()], diff --git a/src/time_estimates.rs b/src/time_estimates.rs index e2cdee8..0aab65e 100644 --- a/src/time_estimates.rs +++ b/src/time_estimates.rs @@ -9,7 +9,7 @@ //! use zxcvbn::zxcvbn; //! use zxcvbn::time_estimates::CrackTimes; //! -//! let entropy = zxcvbn("password123", &[])?; +//! let entropy = zxcvbn("password123", &[]); //! assert_eq!(entropy.crack_times().guesses(), 596); //! assert_eq!(entropy.crack_times().online_throttling_100_per_hour().to_string(), "5 hours"); //! assert_eq!(entropy.crack_times().online_no_throttling_10_per_second().to_string(), "59 seconds");