Renames crate directories and sweeps identifiers so Plan 1B can reference
the post-rename names throughout.
- git mv crates/idfoto-{core,cli,wasm} → crates/relicario-{core,cli,wasm}
- sed sweep: idfoto_core/idfoto-core/IdfotoError/IDFOTO_IMAGE/.idfoto/ etc.
- All 128 relicario-core tests pass post-sweep
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
270 lines
9.2 KiB
Rust
270 lines
9.2 KiB
Rust
//! 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::{RelicarioError, 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<Zeroizing<String>> {
|
||
match req {
|
||
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||
random_password(*length, classes, symbol_charset)
|
||
}
|
||
GeneratorRequest::Bip39 { .. } => Err(RelicarioError::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(RelicarioError::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) => {
|
||
if !s.is_ascii() {
|
||
return Err(RelicarioError::Format(
|
||
"SymbolCharset::Custom must be ASCII-only".into(),
|
||
));
|
||
}
|
||
s.as_bytes()
|
||
}
|
||
};
|
||
charset.extend_from_slice(symbols);
|
||
}
|
||
if charset.is_empty() {
|
||
return Err(RelicarioError::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")))
|
||
}
|
||
|
||
pub fn generate_passphrase(req: &GeneratorRequest) -> Result<Zeroizing<String>> {
|
||
match req {
|
||
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||
bip39_passphrase(*word_count, separator, *capitalization)
|
||
}
|
||
GeneratorRequest::Random { .. } => Err(RelicarioError::Format(
|
||
"use generate_password() for Random requests".into(),
|
||
)),
|
||
}
|
||
}
|
||
|
||
fn bip39_passphrase(word_count: u32, separator: &str, cap: Capitalization) -> Result<Zeroizing<String>> {
|
||
if !matches!(word_count, 3..=12) {
|
||
return Err(RelicarioError::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| RelicarioError::Format(format!("bip39: {e}")))?;
|
||
let words: Vec<String> = 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(RelicarioError::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");
|
||
}
|
||
}
|