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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
|
//! Password and passphrase generators. CSPRNG-only; rejection-sampled to
|
||||||
//! eliminate modulo bias. Strength rating via zxcvbn.
|
//! eliminate modulo bias. Strength rating via zxcvbn.
|
||||||
|
|
||||||
|
use bip39::{Language, Mnemonic};
|
||||||
use rand::distributions::{Distribution, Uniform};
|
use rand::distributions::{Distribution, Uniform};
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
|
use rand::RngCore;
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::error::{IdfotoError, Result};
|
use crate::error::{IdfotoError, Result};
|
||||||
use crate::settings::{CharClasses, GeneratorRequest, SymbolCharset};
|
use crate::settings::{Capitalization, CharClasses, GeneratorRequest, SymbolCharset};
|
||||||
|
|
||||||
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
|
const SAFE_SYMBOLS: &[u8] = b"!@#$%^&*-_=+";
|
||||||
const EXTENDED_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")))
|
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(IdfotoError::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 | 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<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.
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pub use settings::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub mod generators;
|
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 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