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:
adlee-was-taken
2026-04-19 15:02:39 -04:00
parent b2d8a759ef
commit db3f2e15f2
2 changed files with 116 additions and 0 deletions

View 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}");
}
}
}

View File

@@ -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};