diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs new file mode 100644 index 0000000..b40e230 --- /dev/null +++ b/crates/idfoto-core/src/generators.rs @@ -0,0 +1,113 @@ +//! Password and passphrase generators. CSPRNG-only; rejection-sampled to +//! eliminate modulo bias. Strength rating via zxcvbn. + +use rand::distributions::{Distribution, Uniform}; +use rand::rngs::OsRng; +use zeroize::Zeroizing; + +use crate::error::{IdfotoError, Result}; +use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset}; + +const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; +const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?."; +const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; +const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; +const DIGITS: &[u8] = b"0123456789"; + +pub fn generate_password(req: &GeneratorRequest) -> Result> { + match req { + GeneratorRequest::Random { length, classes, symbol_charset } => { + random_password(*length, classes, symbol_charset) + } + GeneratorRequest::Bip39 { .. } => Err(IdfotoError::Format( + "use generate_passphrase() for BIP39 requests".into(), + )), + } +} + +fn random_password( + length: u32, + classes: &CharClasses, + symbol_charset: &SymbolCharset, +) -> Result> { + if length == 0 || length > 128 { + return Err(IdfotoError::Format("length must be 1..=128".into())); + } + let mut charset: Vec = Vec::new(); + if classes.lower { charset.extend_from_slice(LOWER); } + if classes.upper { charset.extend_from_slice(UPPER); } + if classes.digits { charset.extend_from_slice(DIGITS); } + if classes.symbols { + let symbols: &[u8] = match symbol_charset { + SymbolCharset::SafeOnly => SAFE_SYMBOLS, + SymbolCharset::Extended => EXTENDED_SYMBOLS, + SymbolCharset::Custom(s) => s.as_bytes(), + }; + charset.extend_from_slice(symbols); + } + if charset.is_empty() { + return Err(IdfotoError::Format("at least one character class required".into())); + } + + let dist = Uniform::from(0..charset.len()); + let mut rng = OsRng; + let bytes: Vec = (0..length).map(|_| charset[dist.sample(&mut rng)]).collect(); + Ok(Zeroizing::new(String::from_utf8(bytes).expect("ascii-only charset"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_default_password_is_20_chars() { + let req = GeneratorRequest::default(); + let pw = generate_password(&req).unwrap(); + assert_eq!(pw.len(), 20); + } + + #[test] + fn rejects_zero_length() { + let req = GeneratorRequest::Random { + length: 0, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn rejects_no_classes() { + let req = GeneratorRequest::Random { + length: 8, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + assert!(generate_password(&req).is_err()); + } + + #[test] + fn lower_only_password_uses_lowercase() { + let req = GeneratorRequest::Random { + length: 100, + classes: CharClasses { lower: true, upper: false, digits: false, symbols: false }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + assert!(pw.chars().all(|c| c.is_ascii_lowercase())); + } + + #[test] + fn safe_symbols_excludes_quotes_and_brackets() { + let req = GeneratorRequest::Random { + length: 128, + classes: CharClasses { lower: false, upper: false, digits: false, symbols: true }, + symbol_charset: SymbolCharset::SafeOnly, + }; + let pw = generate_password(&req).unwrap(); + for c in pw.chars() { + assert!(!matches!(c, '\'' | '"' | '`' | ',' | ';' | ':' | '{' | '}' | '[' | ']' | '<' | '>' | '(' | ')' | '|' | '\\' | '/' | '?'), + "safe charset must not include {c}"); + } + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 0e3d512..622f340 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -59,6 +59,9 @@ pub use settings::{ SymbolCharset, TrashRetention, VaultSettings, }; +pub mod generators; +pub use generators::generate_password; + pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};