mirror of
https://github.com/elwrcl/pasx.git
synced 2026-10-31 10:58:04 +03:00
init
This commit is contained in:
22 files changed
+11922
No files matched your search
@@ -0,0 +1,219 @@
|
||||
use crate::generator::{GenError, MAX_LENGTH, MIN_LENGTH, RETRY_LIMIT};
|
||||
use rand::RngExt;
|
||||
|
||||
pub const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
pub const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
pub const DIGITS: &str = "0123456789";
|
||||
pub const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.<>?/";
|
||||
pub const AMBIGUOUS: &str = "0Ool1I";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CharsetOptions {
|
||||
pub length: usize,
|
||||
pub upper: bool,
|
||||
pub lower: bool,
|
||||
pub digits: bool,
|
||||
pub symbols: bool,
|
||||
pub exclude_ambiguous: bool,
|
||||
}
|
||||
|
||||
impl Default for CharsetOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
length: 18,
|
||||
upper: true,
|
||||
lower: true,
|
||||
digits: true,
|
||||
symbols: true,
|
||||
exclude_ambiguous: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CharsetOptions {
|
||||
pub fn enabled_count(&self) -> usize {
|
||||
[self.upper, self.lower, self.digits, self.symbols]
|
||||
.iter()
|
||||
.filter(|on| **on)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn classes(&self) -> Vec<Vec<char>> {
|
||||
[
|
||||
(self.upper, UPPER),
|
||||
(self.lower, LOWER),
|
||||
(self.digits, DIGITS),
|
||||
(self.symbols, SYMBOLS),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, set)| {
|
||||
set.chars()
|
||||
.filter(|c| !(self.exclude_ambiguous && AMBIGUOUS.contains(*c)))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pool(&self) -> Vec<char> {
|
||||
self.classes().into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), GenError> {
|
||||
if !(MIN_LENGTH..=MAX_LENGTH).contains(&self.length) {
|
||||
return Err(GenError::InvalidLength {
|
||||
got: self.length,
|
||||
min: MIN_LENGTH,
|
||||
max: MAX_LENGTH,
|
||||
});
|
||||
}
|
||||
if self.enabled_count() == 0 {
|
||||
return Err(GenError::NoCharacterClass);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate<R: RngExt>(opts: &CharsetOptions, rng: &mut R) -> Result<String, GenError> {
|
||||
opts.validate()?;
|
||||
let classes = opts.classes();
|
||||
let pool: Vec<char> = classes.iter().flatten().copied().collect();
|
||||
|
||||
for _ in 0..RETRY_LIMIT {
|
||||
let candidate: Vec<char> = (0..opts.length)
|
||||
.map(|_| pool[rng.random_range(0..pool.len())])
|
||||
.collect();
|
||||
if classes
|
||||
.iter()
|
||||
.all(|class| candidate.iter().any(|c| class.contains(c)))
|
||||
{
|
||||
return Ok(candidate.into_iter().collect());
|
||||
}
|
||||
}
|
||||
Err(GenError::RetryLimitExceeded(RETRY_LIMIT))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
fn rng() -> StdRng {
|
||||
StdRng::seed_from_u64(42)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_requested_length() {
|
||||
let opts = CharsetOptions {
|
||||
length: 24,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert_eq!(out.chars().count(), 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_only_enabled_classes() {
|
||||
let opts = CharsetOptions {
|
||||
length: 32,
|
||||
upper: false,
|
||||
symbols: false,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(
|
||||
out.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_every_enabled_class() {
|
||||
let opts = CharsetOptions {
|
||||
length: 8,
|
||||
..Default::default()
|
||||
};
|
||||
for seed in 0..50 {
|
||||
let out = generate(&opts, &mut StdRng::seed_from_u64(seed)).unwrap();
|
||||
assert!(out.chars().any(|c| c.is_ascii_uppercase()), "{out}");
|
||||
assert!(out.chars().any(|c| c.is_ascii_lowercase()), "{out}");
|
||||
assert!(out.chars().any(|c| c.is_ascii_digit()), "{out}");
|
||||
assert!(out.chars().any(|c| SYMBOLS.contains(c)), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_ambiguous_characters_when_asked() {
|
||||
let opts = CharsetOptions {
|
||||
length: 64,
|
||||
exclude_ambiguous: true,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(out.chars().all(|c| !AMBIGUOUS.contains(c)), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_length_below_minimum() {
|
||||
let opts = CharsetOptions {
|
||||
length: 3,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
generate(&opts, &mut rng()),
|
||||
Err(GenError::InvalidLength {
|
||||
got: 3,
|
||||
min: MIN_LENGTH,
|
||||
max: MAX_LENGTH
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_length_above_maximum() {
|
||||
let opts = CharsetOptions {
|
||||
length: 129,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
generate(&opts, &mut rng()),
|
||||
Err(GenError::InvalidLength {
|
||||
got: 129,
|
||||
min: MIN_LENGTH,
|
||||
max: MAX_LENGTH
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_class_selection() {
|
||||
let opts = CharsetOptions {
|
||||
upper: false,
|
||||
lower: false,
|
||||
digits: false,
|
||||
symbols: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(generate(&opts, &mut rng()), Err(GenError::NoCharacterClass));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pool_character_is_reachable() {
|
||||
let opts = CharsetOptions {
|
||||
length: 16,
|
||||
..Default::default()
|
||||
};
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut r = rng();
|
||||
for _ in 0..10_000 {
|
||||
seen.extend(generate(&opts, &mut r).unwrap().chars());
|
||||
}
|
||||
for c in opts.pool() {
|
||||
assert!(
|
||||
seen.contains(&c),
|
||||
"pool character {c:?} was never generated"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use crate::generator::charset::CharsetOptions;
|
||||
use crate::generator::passphrase::{PassphraseOptions, WORDS};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Strength {
|
||||
Weak,
|
||||
Fair,
|
||||
Strong,
|
||||
Excellent,
|
||||
}
|
||||
|
||||
impl Strength {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Strength::Weak => "weak",
|
||||
Strength::Fair => "fair",
|
||||
Strength::Strong => "strong",
|
||||
Strength::Excellent => "excellent",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This is an upper bound, not an exact figure: generation is uniform over the
|
||||
// set of passwords with every enabled class represented (see charset.rs's
|
||||
// rejection sampling), which is smaller than the full pool^length space this
|
||||
// formula assumes. The gap is small and shrinks with length — material only
|
||||
// at the short end of the supported range, where the result is already
|
||||
// labelled weak — so it is reported as-is rather than computed exactly.
|
||||
pub fn charset_bits(opts: &CharsetOptions) -> f64 {
|
||||
let pool = opts.pool().len();
|
||||
if pool == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
opts.length as f64 * (pool as f64).log2()
|
||||
}
|
||||
|
||||
// `capitalize` is deliberately absent from this figure: it is a deterministic
|
||||
// transform (always titlecase, never a per-word coin flip), so it adds no
|
||||
// uncertainty and must not be counted as if it did. Only `add_number`, which
|
||||
// draws a random digit, adds bits.
|
||||
pub fn passphrase_bits(opts: &PassphraseOptions) -> f64 {
|
||||
let per_word = (WORDS.len() as f64).log2();
|
||||
let digit = if opts.add_number { 10f64.log2() } else { 0.0 };
|
||||
opts.words as f64 * per_word + digit
|
||||
}
|
||||
|
||||
pub fn strength(bits: f64) -> Strength {
|
||||
match bits {
|
||||
b if b < 50.0 => Strength::Weak,
|
||||
b if b < 80.0 => Strength::Fair,
|
||||
b if b < 110.0 => Strength::Strong,
|
||||
_ => Strength::Excellent,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn full_pool_is_eighty_eight_characters() {
|
||||
assert_eq!(CharsetOptions::default().pool().len(), 88);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charset_bits_match_the_pool_formula() {
|
||||
let opts = CharsetOptions {
|
||||
length: 18,
|
||||
..Default::default()
|
||||
};
|
||||
let bits = charset_bits(&opts);
|
||||
assert!((bits - 18.0 * 88f64.log2()).abs() < 1e-9);
|
||||
assert!((bits - 116.3).abs() < 0.1, "{bits}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passphrase_bits_are_about_twelve_nine_per_word() {
|
||||
let opts = PassphraseOptions {
|
||||
words: 5,
|
||||
..Default::default()
|
||||
};
|
||||
let bits = passphrase_bits(&opts);
|
||||
assert!((bits - 5.0 * 7776f64.log2()).abs() < 1e-9);
|
||||
assert!((bits - 64.6).abs() < 0.1, "{bits}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalizing_adds_no_entropy() {
|
||||
let plain = PassphraseOptions::default();
|
||||
let capitalized = PassphraseOptions {
|
||||
capitalize: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(passphrase_bits(&plain), passphrase_bits(&capitalized));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_a_digit_adds_log2_of_ten() {
|
||||
let plain = PassphraseOptions::default();
|
||||
let numbered = PassphraseOptions {
|
||||
add_number: true,
|
||||
..Default::default()
|
||||
};
|
||||
let delta = passphrase_bits(&numbered) - passphrase_bits(&plain);
|
||||
assert!((delta - 10f64.log2()).abs() < 1e-9, "{delta}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strength_bands_split_at_fifty_eighty_and_one_ten() {
|
||||
assert_eq!(strength(49.9).label(), "weak");
|
||||
assert_eq!(strength(50.0).label(), "fair");
|
||||
assert_eq!(strength(79.9).label(), "fair");
|
||||
assert_eq!(strength(80.0).label(), "strong");
|
||||
assert_eq!(strength(109.9).label(), "strong");
|
||||
assert_eq!(strength(110.0).label(), "excellent");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod charset;
|
||||
pub mod entropy;
|
||||
pub mod passphrase;
|
||||
|
||||
pub const MIN_LENGTH: usize = 4;
|
||||
pub const MAX_LENGTH: usize = 128;
|
||||
pub const MIN_WORDS: usize = 3;
|
||||
pub const MAX_WORDS: usize = 12;
|
||||
pub const RETRY_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum GenError {
|
||||
#[error("length {got} is out of range {min}..={max}")]
|
||||
InvalidLength { got: usize, min: usize, max: usize },
|
||||
#[error("at least one character class must be enabled")]
|
||||
NoCharacterClass,
|
||||
#[error("word count {got} is out of range {min}..={max}")]
|
||||
InvalidWordCount { got: usize, min: usize, max: usize },
|
||||
#[error("could not satisfy character class constraints after {0} attempts")]
|
||||
RetryLimitExceeded(usize),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn errors_render_readable_messages() {
|
||||
let err = GenError::InvalidLength {
|
||||
got: 2,
|
||||
min: MIN_LENGTH,
|
||||
max: MAX_LENGTH,
|
||||
};
|
||||
assert_eq!(err.to_string(), "length 2 is out of range 4..=128");
|
||||
assert_eq!(
|
||||
GenError::NoCharacterClass.to_string(),
|
||||
"at least one character class must be enabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
use crate::generator::{GenError, MAX_WORDS, MIN_WORDS};
|
||||
use rand::RngExt;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static WORDS: LazyLock<Vec<&'static str>> =
|
||||
LazyLock::new(|| include_str!("wordlist.txt").lines().collect());
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PassphraseOptions {
|
||||
pub words: usize,
|
||||
pub separator: char,
|
||||
pub capitalize: bool,
|
||||
pub add_number: bool,
|
||||
}
|
||||
|
||||
impl Default for PassphraseOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
words: 5,
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
add_number: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PassphraseOptions {
|
||||
pub fn validate(&self) -> Result<(), GenError> {
|
||||
if !(MIN_WORDS..=MAX_WORDS).contains(&self.words) {
|
||||
return Err(GenError::InvalidWordCount {
|
||||
got: self.words,
|
||||
min: MIN_WORDS,
|
||||
max: MAX_WORDS,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn generate<R: RngExt>(opts: &PassphraseOptions, rng: &mut R) -> Result<String, GenError> {
|
||||
opts.validate()?;
|
||||
|
||||
let words: Vec<String> = (0..opts.words)
|
||||
.map(|_| {
|
||||
let word = WORDS[rng.random_range(0..WORDS.len())];
|
||||
if opts.capitalize {
|
||||
capitalize(word)
|
||||
} else {
|
||||
word.to_owned()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut out = words.join(&opts.separator.to_string());
|
||||
if opts.add_number {
|
||||
out.push(char::from_digit(rng.random_range(0..10), 10).unwrap_or('0'));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn capitalize(word: &str) -> String {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::SeedableRng;
|
||||
use rand::rngs::StdRng;
|
||||
|
||||
fn rng() -> StdRng {
|
||||
StdRng::seed_from_u64(7)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordlist_has_the_expected_size() {
|
||||
assert_eq!(WORDS.len(), 7776);
|
||||
assert!(WORDS.iter().all(|w| !w.is_empty()));
|
||||
}
|
||||
|
||||
// Four wordlist entries contain a hyphen, so tests that split the output
|
||||
// back into words use a separator that cannot occur inside a word.
|
||||
const SPLITTABLE: char = '.';
|
||||
|
||||
#[test]
|
||||
fn produces_the_requested_word_count() {
|
||||
let opts = PassphraseOptions {
|
||||
words: 6,
|
||||
separator: SPLITTABLE,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert_eq!(out.split(SPLITTABLE).count(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_word_comes_from_the_list() {
|
||||
let opts = PassphraseOptions {
|
||||
separator: SPLITTABLE,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(out.split(SPLITTABLE).all(|w| WORDS.contains(&w)), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_words_with_the_configured_separator() {
|
||||
let opts = PassphraseOptions {
|
||||
separator: SPLITTABLE,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert_eq!(out.matches(SPLITTABLE).count(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_a_hyphen_separator() {
|
||||
let opts = PassphraseOptions::default();
|
||||
assert_eq!(opts.separator, '-');
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(out.matches('-').count() >= 4, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capitalizes_each_word_when_asked() {
|
||||
let opts = PassphraseOptions {
|
||||
capitalize: true,
|
||||
separator: SPLITTABLE,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(
|
||||
out.split(SPLITTABLE)
|
||||
.all(|w| w.chars().next().is_some_and(|c| c.is_ascii_uppercase())),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_a_digit_when_asked() {
|
||||
let opts = PassphraseOptions {
|
||||
add_number: true,
|
||||
..Default::default()
|
||||
};
|
||||
let out = generate(&opts, &mut rng()).unwrap();
|
||||
assert!(
|
||||
out.chars().last().is_some_and(|c| c.is_ascii_digit()),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_word_counts_outside_the_bounds() {
|
||||
let too_few = PassphraseOptions {
|
||||
words: 2,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
generate(&too_few, &mut rng()),
|
||||
Err(GenError::InvalidWordCount {
|
||||
got: 2,
|
||||
min: MIN_WORDS,
|
||||
max: MAX_WORDS
|
||||
})
|
||||
);
|
||||
let too_many = PassphraseOptions {
|
||||
words: 13,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
generate(&too_many, &mut rng()),
|
||||
Err(GenError::InvalidWordCount {
|
||||
got: 13,
|
||||
min: MIN_WORDS,
|
||||
max: MAX_WORDS
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user