//! Password and passphrase generators. CSPRNG-only; rejection-sampled to //! eliminate modulo bias. Strength rating via zxcvbn. use bip39::{Language, Mnemonic}; use rand::distributions::{Distribution, Uniform}; use rand::rngs::OsRng; use rand::RngCore; use zeroize::Zeroizing; use crate::error::{IdfotoError, Result}; use crate::settings::{Capitalization, 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) => { if !s.is_ascii() { return Err(IdfotoError::Format( "SymbolCharset::Custom must be ASCII-only".into(), )); } 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"))) } pub fn generate_passphrase(req: &GeneratorRequest) -> Result> { match req { GeneratorRequest::Bip39 { word_count, separator, capitalization } => { bip39_passphrase(*word_count, separator, *capitalization) } GeneratorRequest::Random { .. } => Err(IdfotoError::Format( "use generate_password() for Random requests".into(), )), } } fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result> { if !matches!(word_count, 3..=12) { return Err(IdfotoError::Format("word_count must be 3..=12".into())); } // bip39 v2 requires entropy 128–256 bits in multiples of 32 bits (4 bytes). // We always generate 128 bits (16 bytes) → 12 words, then take the first // word_count words. This gives full-entropy sourcing even for short passphrases. let mut entropy = Zeroizing::new([0u8; 16]); OsRng.fill_bytes(entropy.as_mut_slice()); let m = Mnemonic::from_entropy_in(Language::English, entropy.as_slice()) .map_err(|e| IdfotoError::Format(format!("bip39: {e}")))?; let words: Vec = m.words().take(word_count as usize).map(|w| { match cap { Capitalization::Lower => w.to_ascii_lowercase(), Capitalization::Upper => w.to_ascii_uppercase(), Capitalization::FirstOfEach | Capitalization::Title => { let mut chars = w.chars(); chars.next().map(|c| c.to_ascii_uppercase().to_string()) .unwrap_or_default() + chars.as_str() } Capitalization::Mixed => { w.chars().enumerate().map(|(i, c)| { if i % 2 == 0 { c.to_ascii_uppercase() } else { c } }).collect() } } }).collect(); Ok(Zeroizing::new(words.join(separator))) } /// Returns zxcvbn's 0-4 score (higher is stronger) and the estimated guesses. #[derive(Debug, Clone, Copy)] pub struct StrengthEstimate { pub score: u8, pub guesses_log10: f64, } pub fn rate_passphrase(p: &str) -> StrengthEstimate { let est = zxcvbn::zxcvbn(p, &[]); StrengthEstimate { score: est.score().into(), guesses_log10: est.guesses_log10(), } } /// Strength gate at vault creation (audit H3): require score >= 3. pub fn validate_passphrase_strength(p: &str) -> Result<()> { let est = rate_passphrase(p); if est.score < 3 { return Err(IdfotoError::WeakPassphrase { score: est.score }); } Ok(()) } #[cfg(test)] mod bip39_tests { use super::*; #[test] fn bip39_default_is_5_space_separated_words() { let req = GeneratorRequest::Bip39 { word_count: 5, separator: " ".into(), capitalization: Capitalization::Lower, }; let pw = generate_passphrase(&req).unwrap(); assert_eq!(pw.split(' ').count(), 5); } #[test] fn bip39_dash_separator() { let req = GeneratorRequest::Bip39 { word_count: 4, separator: "-".into(), capitalization: Capitalization::Lower, }; let pw = generate_passphrase(&req).unwrap(); assert_eq!(pw.split('-').count(), 4); assert!(!pw.contains(' ')); } #[test] fn bip39_first_of_each_capitalizes() { let req = GeneratorRequest::Bip39 { word_count: 5, separator: " ".into(), capitalization: Capitalization::FirstOfEach, }; let pw = generate_passphrase(&req).unwrap(); for word in pw.split(' ') { let first = word.chars().next().unwrap(); assert!(first.is_ascii_uppercase(), "word {word} should start uppercase"); } } #[test] fn bip39_rejects_bad_word_count() { let req = GeneratorRequest::Bip39 { word_count: 2, separator: " ".into(), capitalization: Capitalization::Lower, }; assert!(generate_passphrase(&req).is_err()); } #[test] fn rate_passphrase_strong_one_passes_gate() { // 6-word bip39 passphrase let req = GeneratorRequest::Bip39 { word_count: 6, separator: " ".into(), capitalization: Capitalization::Lower, }; let pw = generate_passphrase(&req).unwrap(); assert!(validate_passphrase_strength(&pw).is_ok()); } #[test] fn rate_passphrase_weak_fails_gate() { assert!(validate_passphrase_strength("password").is_err()); assert!(validate_passphrase_strength("12345678").is_err()); assert!(validate_passphrase_strength("hunter2").is_err()); } } #[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}"); } } #[test] fn custom_charset_rejects_non_ascii() { let req = GeneratorRequest::Random { length: 8, classes: CharClasses { lower: false, upper: false, digits: false, symbols: true }, symbol_charset: SymbolCharset::Custom("ñé".into()), }; let err = generate_password(&req); assert!(err.is_err(), "non-ASCII custom charset must be rejected"); } }