This commit is contained in:
elwrcl committed 2026-09-26 17:09:38 +03:00
commit 6df73cc246
22 files changed
+11922

No files matched your search

+219
View File
@@ -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"
);
}
}
}
+117
View File
@@ -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");
}
}
+42
View File
@@ -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"
);
}
}
+183
View File
@@ -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