Skip to content

Commit

Permalink
Make zxcvbn infallible
Browse files Browse the repository at this point in the history
  • Loading branch information
Randolf J committed May 11, 2024
1 parent 958c5da commit bdb42d2
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 87 deletions.
16 changes: 10 additions & 6 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
}
```
Expand Down
6 changes: 3 additions & 3 deletions src/feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
140 changes: 81 additions & 59 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -33,6 +35,36 @@ pub mod matching;
mod scoring;
pub mod time_estimates;

#[cfg(not(target_arch = "wasm32"))]
fn time_scoped<F, R>(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, R>(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))]
Expand Down Expand Up @@ -92,76 +124,54 @@ 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<Duration, ZxcvbnError> {
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<Duration, ZxcvbnError> {
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<Entropy, ZxcvbnError> {
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::<String>();

// 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::<String>();
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,
score,
feedback,
sequence: result.sequence,
calc_time,
})
}
}

#[cfg(test)]
mod tests {
use super::*;

use quickcheck::TestResult;

#[cfg(target_arch = "wasm32")]
Expand All @@ -170,14 +180,14 @@ mod tests {
quickcheck! {
fn test_zxcvbn_doesnt_panic(password: String, user_inputs: Vec<String>) -> TestResult {
let inputs = user_inputs.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
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<String>) -> TestResult {
let inputs = user_inputs.iter().map(|s| s.as_ref()).collect::<Vec<&str>>();
serde_json::to_string(&zxcvbn(&password, &inputs).ok()).ok();
serde_json::to_string(&zxcvbn(&password, &inputs)).ok();
TestResult::from_bool(true)
}
}
Expand All @@ -186,43 +196,55 @@ 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());
assert!(entropy.feedback.is_none());
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);
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[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);
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[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);
}

#[cfg_attr(not(target_arch = "wasm32"), test)]
#[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);
Expand All @@ -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);
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down
4 changes: 1 addition & 3 deletions src/matching/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 9 additions & 11 deletions src/scoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,19 @@ struct Optimal {
g: Vec<HashMap<usize, u64>>,
}

#[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;
Expand Down Expand Up @@ -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()],
Expand Down
Loading

0 comments on commit bdb42d2

Please sign in to comment.