Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make zxcvbn infallible #78

Merged
merged 1 commit into from
May 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -990,7 +990,7 @@
} else {
panic!("Wrong match pattern")
};
assert_eq!(p.reversed, true);

Check warning on line 993 in src/matching/mod.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

used `assert_eq!` with a literal bool
}
}

Expand Down Expand Up @@ -1063,7 +1063,7 @@
} else {
panic!("Wrong match pattern")
};
assert_eq!(p.l33t, true);

Check warning on line 1066 in src/matching/mod.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

used `assert_eq!` with a literal bool
}
}

Expand All @@ -1082,7 +1082,7 @@
} else {
panic!("Wrong match pattern")
};
assert_eq!(p.l33t, true);

Check warning on line 1085 in src/matching/mod.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

used `assert_eq!` with a literal bool
}
}

Expand Down Expand Up @@ -1208,7 +1208,7 @@
panic!("Wrong match pattern")
};
assert_eq!(p.sequence_name, "lower");
assert_eq!(p.ascending, false);

Check warning on line 1211 in src/matching/mod.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

used `assert_eq!` with a literal bool
}

#[test]
Expand Down Expand Up @@ -1442,9 +1442,7 @@

#[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 @@
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 @@ -500,7 +499,7 @@

quickcheck! {
fn test_n_ck_mul_overflow(n: usize, k: usize) -> TestResult {
if n >= 63 && n <= 100 {

Check warning on line 502 in src/scoring.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

manual `RangeInclusive::contains` implementation
scoring::n_ck(n, k); // Must not panic
TestResult::from_bool(true)
} else {
Expand Down Expand Up @@ -793,7 +792,7 @@
};
assert_eq!(
p.estimate(token),
(*scoring::REFERENCE_YEAR - 1972).abs() as u64

Check warning on line 795 in src/scoring.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

casting the result of `i32::abs()` to u64
);
}

Expand All @@ -809,8 +808,7 @@

#[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 All @@ -829,7 +827,7 @@
let token = "1123";
assert_eq!(
p.estimate(token),
365 * (*scoring::REFERENCE_YEAR - p.year).abs() as u64

Check warning on line 830 in src/scoring.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

casting the result of `i32::abs()` to u64
);
}

Expand Down Expand Up @@ -857,7 +855,7 @@
let base_guesses = *scoring::KEYBOARD_STARTING_POSITIONS
* *scoring::KEYBOARD_AVERAGE_DEGREE
* (token.len() - 1) as u64;
assert_eq!(p.estimate(token), base_guesses as u64);

Check warning on line 858 in src/scoring.rs

View workflow job for this annotation

GitHub Actions / clippy-rustfmt

casting to the same type is unnecessary (`u64` -> `u64`)
}

#[test]
Expand Down
Loading
Loading