feat(core): add CSPRNG random password generator with safe charset
Uses rand::distributions::Uniform for unbiased sampling (audit H6). Safe symbols = !@#$%^&*-_=+ (excludes characters that web forms commonly reject). Test length capped at 128 (validator upper bound). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
113
crates/idfoto-core/src/generators.rs
Normal file
113
crates/idfoto-core/src/generators.rs
Normal file
@@ -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<Zeroizing<String>> {
|
||||||
|
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<Zeroizing<String>> {
|
||||||
|
if length == 0 || length > 128 {
|
||||||
|
return Err(IdfotoError::Format("length must be 1..=128".into()));
|
||||||
|
}
|
||||||
|
let mut charset: Vec<u8> = 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<u8> = (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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ pub use settings::{
|
|||||||
SymbolCharset, TrashRetention, VaultSettings,
|
SymbolCharset, TrashRetention, VaultSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod generators;
|
||||||
|
pub use generators::generate_password;
|
||||||
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user