diff --git a/crates/bitwarden/src/tool/generators/client_generator.rs b/crates/bitwarden/src/tool/generators/client_generator.rs index 0384eec6a..fa1f397c8 100644 --- a/crates/bitwarden/src/tool/generators/client_generator.rs +++ b/crates/bitwarden/src/tool/generators/client_generator.rs @@ -1,8 +1,7 @@ use crate::{ error::Result, - tool::generators::password::{ - passphrase, password, PassphraseGeneratorRequest, PasswordGeneratorRequest, - }, + tool::generators::passphrase::{passphrase, PassphraseGeneratorRequest}, + tool::generators::password::{password, PasswordGeneratorRequest}, Client, }; diff --git a/crates/bitwarden/src/tool/generators/mod.rs b/crates/bitwarden/src/tool/generators/mod.rs index bdc0fb260..31c7c3e47 100644 --- a/crates/bitwarden/src/tool/generators/mod.rs +++ b/crates/bitwarden/src/tool/generators/mod.rs @@ -1,4 +1,6 @@ mod client_generator; +mod passphrase; mod password; -pub use password::{PassphraseGeneratorRequest, PasswordGeneratorRequest}; +pub use passphrase::PassphraseGeneratorRequest; +pub use password::PasswordGeneratorRequest; diff --git a/crates/bitwarden/src/tool/generators/passphrase.rs b/crates/bitwarden/src/tool/generators/passphrase.rs new file mode 100644 index 000000000..ad71eec2e --- /dev/null +++ b/crates/bitwarden/src/tool/generators/passphrase.rs @@ -0,0 +1,82 @@ +use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST}; +use rand::{seq::SliceRandom, Rng, RngCore}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Passphrase generator request. +/// +/// The default separator is `-` and default number of words is 3. +#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Record))] +pub struct PassphraseGeneratorRequest { + pub num_words: Option, + pub word_separator: Option, + pub capitalize: Option, + pub include_number: Option, +} + +const DEFAULT_PASSPHRASE_NUM_WORDS: u8 = 3; +const DEFAULT_PASSPHRASE_SEPARATOR: char = ' '; + +pub(super) fn passphrase(input: PassphraseGeneratorRequest) -> Result { + let words = input.num_words.unwrap_or(DEFAULT_PASSPHRASE_NUM_WORDS); + let separator = input + .word_separator + .and_then(|s| s.chars().next()) + .unwrap_or(DEFAULT_PASSPHRASE_SEPARATOR); + let capitalize = input.capitalize.unwrap_or(false); + let include_number = input.include_number.unwrap_or(false); + + let mut rand = rand::thread_rng(); + + let mut passphrase_words = gen_words(&mut rand, words); + if include_number { + let number_idx = rand.gen_range(0..words as usize); + passphrase_words[number_idx].push_str(&rand.gen_range(0..=9).to_string()); + } + + if capitalize { + passphrase_words = passphrase_words + .iter() + .map(|w| capitalize_first_letter(w)) + .collect(); + } + + Ok(passphrase_words.join(&separator.to_string())) +} + +fn gen_words(mut rng: impl RngCore, num_words: u8) -> Vec { + (0..num_words) + .map(|_| { + EFF_LONG_WORD_LIST + .choose(&mut rng) + .expect("slice is not empty") + .to_string() + }) + .collect() +} + +fn capitalize_first_letter(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[cfg(test)] +mod tests { + + #[test] + fn test_capitalize() { + assert_eq!(super::capitalize_first_letter("hello"), "Hello"); + assert_eq!(super::capitalize_first_letter("1ello"), "1ello"); + assert_eq!(super::capitalize_first_letter("Hello"), "Hello"); + assert_eq!(super::capitalize_first_letter("h"), "H"); + assert_eq!(super::capitalize_first_letter(""), ""); + + // Also supports non-ascii, though the EFF list doesn't have any + assert_eq!(super::capitalize_first_letter("áéíóú"), "Áéíóú"); + } +} diff --git a/crates/bitwarden/src/tool/generators/password.rs b/crates/bitwarden/src/tool/generators/password.rs index 8748305cb..237394f56 100644 --- a/crates/bitwarden/src/tool/generators/password.rs +++ b/crates/bitwarden/src/tool/generators/password.rs @@ -1,5 +1,4 @@ -use crate::{error::Result, wordlist::EFF_LONG_WORD_LIST}; -use rand::{seq::SliceRandom, Rng}; +use crate::error::Result; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -28,68 +27,6 @@ pub struct PasswordGeneratorRequest { pub min_special: Option, } -/// Passphrase generator request. -/// -/// The default separator is `-` and default number of words is 3. -#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[cfg_attr(feature = "mobile", derive(uniffi::Record))] -pub struct PassphraseGeneratorRequest { - pub num_words: Option, - pub word_separator: Option, - pub capitalize: Option, - pub include_number: Option, -} - pub(super) fn password(_input: PasswordGeneratorRequest) -> Result { Ok("pa11w0rd".to_string()) } - -const DEFAULT_PASSPHRASE_NUM_WORDS: u8 = 3; -const DEFAULT_PASSPHRASE_SEPARATOR: char = ' '; - -pub(super) fn passphrase(input: PassphraseGeneratorRequest) -> Result { - let words = input.num_words.unwrap_or(DEFAULT_PASSPHRASE_NUM_WORDS); - let separator = input - .word_separator - .and_then(|s| s.chars().next()) - .unwrap_or(DEFAULT_PASSPHRASE_SEPARATOR); - - let capitalize = input.capitalize.unwrap_or(false); - let include_number = input.include_number.unwrap_or(false); - - fn capitalize_first_letter(s: &str) -> String { - let mut c = s.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::() + c.as_str(), - } - } - - let mut rand = rand::thread_rng(); - - let insert_number_idx = include_number.then(|| rand.gen_range(0..words)); - let mut passphrase = String::new(); - - for idx in 0..words { - let word = EFF_LONG_WORD_LIST - .choose(&mut rand) - .expect("slice is not empty"); - - if capitalize { - passphrase.push_str(&capitalize_first_letter(word)); - } else { - passphrase.push_str(word); - } - - if insert_number_idx == Some(idx) { - passphrase.push_str(&rand.gen_range(0..=9).to_string()); - } - - if idx != words - 1 { - passphrase.push(separator) - } - } - - Ok(passphrase) -}