From 61b1a9710ba8842aec0e934c260fa06909f4666c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:31:50 -0400 Subject: [PATCH] feat(core): add BIP39 passphrase generator + zxcvbn strength gate generate_passphrase honors word_count (3-12), separator, capitalization. validate_passphrase_strength enforces zxcvbn score >= 3 (audit H3). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/generators.rs | 139 ++++++++++++++++++++++++++- crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/idfoto-core/src/generators.rs b/crates/idfoto-core/src/generators.rs index 671583d..d8e941c 100644 --- a/crates/idfoto-core/src/generators.rs +++ b/crates/idfoto-core/src/generators.rs @@ -1,12 +1,14 @@ //! 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::{CharClasses, GeneratorRequest, SymbolCharset}; +use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset}; const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+"; const EXTENDED_SYMBOLS: &[u8] = b"!@#$%^&*-_=+~?."; @@ -62,6 +64,141 @@ fn random_password( 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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 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. +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() { + // 5-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::*; diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 622f340..569055f 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -60,7 +60,7 @@ pub use settings::{ }; pub mod generators; -pub use generators::generate_password; +pub use generators::{generate_passphrase, generate_password, rate_passphrase, validate_passphrase_strength, StrengthEstimate}; pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};