From 7fc3b8436612b6218ca039ff530e12e7d7232a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Mon, 4 Dec 2023 19:19:11 +0100 Subject: [PATCH] [PM-3435] Passphrase generator (#262) ## Type of change ``` - [ ] Bug fix - [x] New feature development - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Build/deploy pipeline (DevOps) - [ ] Other ``` ## Objective Added passphrase generator, including `bw` cli support --------- Co-authored-by: Hinton --- .../src/tool/generators/client_generator.rs | 26 +- crates/bitwarden/src/tool/generators/mod.rs | 4 +- .../src/tool/generators/passphrase.rs | 236 ++++++++++++++++++ .../bitwarden/src/tool/generators/password.rs | 17 -- crates/bw/src/main.rs | 32 ++- languages/kotlin/doc.md | 16 +- 6 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 crates/bitwarden/src/tool/generators/passphrase.rs diff --git a/crates/bitwarden/src/tool/generators/client_generator.rs b/crates/bitwarden/src/tool/generators/client_generator.rs index 0384eec6a..8af8ecd19 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, }; @@ -15,6 +14,27 @@ impl<'a> ClientGenerator<'a> { password(input) } + /// Generates a random passphrase. + /// A passphrase is a combination of random words separated by a character. + /// An example of passphrase is `correct horse battery staple`. + /// + /// The number of words and their case, the word separator, and the inclusion of + /// a number in the passphrase can be customized using the `input` parameter. + /// + /// # Examples + /// + /// ``` + /// use bitwarden::{Client, tool::PassphraseGeneratorRequest, error::Result}; + /// async fn test() -> Result<()> { + /// let input = PassphraseGeneratorRequest { + /// num_words: 4, + /// ..Default::default() + /// }; + /// let passphrase = Client::new(None).generator().passphrase(input).await.unwrap(); + /// println!("{}", passphrase); + /// Ok(()) + /// } + /// ``` pub async fn passphrase(&self, input: PassphraseGeneratorRequest) -> Result { passphrase(input) } diff --git a/crates/bitwarden/src/tool/generators/mod.rs b/crates/bitwarden/src/tool/generators/mod.rs index 4d991b765..12c526930 100644 --- a/crates/bitwarden/src/tool/generators/mod.rs +++ b/crates/bitwarden/src/tool/generators/mod.rs @@ -1,5 +1,7 @@ mod client_generator; +mod passphrase; mod password; pub use client_generator::ClientGenerator; -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..e62fa8f9a --- /dev/null +++ b/crates/bitwarden/src/tool/generators/passphrase.rs @@ -0,0 +1,236 @@ +use crate::{ + error::{Error, Result}, + wordlist::EFF_LONG_WORD_LIST, +}; +use rand::{seq::SliceRandom, Rng, RngCore}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Passphrase generator request options. +#[derive(Serialize, Deserialize, Debug, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[cfg_attr(feature = "mobile", derive(uniffi::Record))] +pub struct PassphraseGeneratorRequest { + /// Number of words in the generated passphrase. + /// This value must be between 3 and 20. + pub num_words: u8, + /// Character separator between words in the generated passphrase. The value cannot be empty. + pub word_separator: String, + /// When set to true, capitalize the first letter of each word in the generated passphrase. + pub capitalize: bool, + /// When set to true, include a number at the end of one of the words in the generated passphrase. + pub include_number: bool, +} + +impl Default for PassphraseGeneratorRequest { + fn default() -> Self { + Self { + num_words: 3, + word_separator: ' '.to_string(), + capitalize: false, + include_number: false, + } + } +} + +const MINIMUM_PASSPHRASE_NUM_WORDS: u8 = 3; +const MAXIMUM_PASSPHRASE_NUM_WORDS: u8 = 20; + +/// Represents a set of valid options to generate a passhprase with. +/// To get an instance of it, use [`PassphraseGeneratorRequest::validate_options`](PassphraseGeneratorRequest::validate_options) +struct ValidPassphraseGeneratorOptions { + pub(super) num_words: u8, + pub(super) word_separator: String, + pub(super) capitalize: bool, + pub(super) include_number: bool, +} + +impl PassphraseGeneratorRequest { + /// Validates the request and returns an immutable struct with valid options to use with the passphrase generator. + fn validate_options(self) -> Result { + // TODO: Add password generator policy checks + + if !(MINIMUM_PASSPHRASE_NUM_WORDS..=MAXIMUM_PASSPHRASE_NUM_WORDS).contains(&self.num_words) + { + return Err(Error::Internal("'num_words' must be between 3 and 20")); + } + + if self.word_separator.chars().next().is_none() { + return Err(Error::Internal("'word_separator' cannot be empty")); + }; + + Ok(ValidPassphraseGeneratorOptions { + num_words: self.num_words, + word_separator: self.word_separator, + capitalize: self.capitalize, + include_number: self.include_number, + }) + } +} + +/// Implementation of the random passphrase generator. This is not accessible to the public API. +/// See [`ClientGenerator::passphrase`](crate::ClientGenerator::passphrase) for the API function. +/// +/// # Arguments: +/// * `options`: Valid parameters used to generate the passphrase. To create it, use +/// [`PassphraseGeneratorRequest::validate_options`](PassphraseGeneratorRequest::validate_options). +pub(super) fn passphrase(request: PassphraseGeneratorRequest) -> Result { + let options = request.validate_options()?; + Ok(passphrase_with_rng(rand::thread_rng(), options)) +} + +fn passphrase_with_rng(mut rng: impl RngCore, options: ValidPassphraseGeneratorOptions) -> String { + let mut passphrase_words = gen_words(&mut rng, options.num_words); + if options.include_number { + include_number_in_words(&mut rng, &mut passphrase_words); + } + if options.capitalize { + capitalize_words(&mut passphrase_words); + } + passphrase_words.join(&options.word_separator) +} + +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 include_number_in_words(mut rng: impl RngCore, words: &mut [String]) { + let number_idx = rng.gen_range(0..words.len()); + words[number_idx].push_str(&rng.gen_range(0..=9).to_string()); +} + +fn capitalize_words(words: &mut [String]) { + words + .iter_mut() + .for_each(|w| *w = capitalize_first_letter(w)); +} + +fn capitalize_first_letter(s: &str) -> String { + // Unicode case conversion can change the length of the string, so we can't capitalize in place. + // Instead we extract the first character and convert it to uppercase. This returns + // an iterator which we collect into a string, and then append the rest of the input. + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +#[cfg(test)] +mod tests { + use rand::SeedableRng; + + use super::*; + + #[test] + fn test_gen_words() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + assert_eq!( + &gen_words(&mut rng, 4), + &["subsystem", "undertook", "silenced", "dinginess"] + ); + assert_eq!(&gen_words(&mut rng, 1), &["numbing"]); + assert_eq!(&gen_words(&mut rng, 2), &["catnip", "jokester"]); + } + + #[test] + fn test_capitalize() { + assert_eq!(capitalize_first_letter("hello"), "Hello"); + assert_eq!(capitalize_first_letter("1ello"), "1ello"); + assert_eq!(capitalize_first_letter("Hello"), "Hello"); + assert_eq!(capitalize_first_letter("h"), "H"); + assert_eq!(capitalize_first_letter(""), ""); + + // Also supports non-ascii, though the EFF list doesn't have any + assert_eq!(capitalize_first_letter("áéíóú"), "Áéíóú"); + } + + #[test] + fn test_capitalize_words() { + let mut words = vec!["hello".into(), "world".into()]; + capitalize_words(&mut words); + assert_eq!(words, &["Hello", "World"]); + } + + #[test] + fn test_include_number() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + + let mut words = vec!["hello".into(), "world".into()]; + include_number_in_words(&mut rng, &mut words); + assert_eq!(words, &["hello", "world7"]); + + let mut words = vec!["This".into(), "is".into(), "a".into(), "test".into()]; + include_number_in_words(&mut rng, &mut words); + assert_eq!(words, &["This", "is", "a1", "test"]); + } + + #[test] + fn test_separator() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + + let input = PassphraseGeneratorRequest { + num_words: 4, + word_separator: "👨🏻‍❤️‍💋‍👨🏻".into(), // This emoji is 35 bytes long, but represented as a single character + capitalize: false, + include_number: true, + } + .validate_options() + .unwrap(); + assert_eq!( + passphrase_with_rng(&mut rng, input), + "subsystem4👨🏻‍❤️‍💋‍👨🏻undertook👨🏻‍❤️‍💋‍👨🏻silenced👨🏻‍❤️‍💋‍👨🏻dinginess" + ); + } + + #[test] + fn test_passphrase() { + let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + + let input = PassphraseGeneratorRequest { + num_words: 4, + word_separator: "-".into(), + capitalize: true, + include_number: true, + } + .validate_options() + .unwrap(); + assert_eq!( + passphrase_with_rng(&mut rng, input), + "Subsystem4-Undertook-Silenced-Dinginess" + ); + + let input = PassphraseGeneratorRequest { + num_words: 3, + word_separator: " ".into(), + capitalize: false, + include_number: true, + } + .validate_options() + .unwrap(); + assert_eq!( + passphrase_with_rng(&mut rng, input), + "drew7 hankering cabana" + ); + + let input = PassphraseGeneratorRequest { + num_words: 5, + word_separator: ";".into(), + capitalize: false, + include_number: false, + } + .validate_options() + .unwrap(); + assert_eq!( + passphrase_with_rng(&mut rng, input), + "duller;backlight;factual;husked;remover" + ); + } +} diff --git a/crates/bitwarden/src/tool/generators/password.rs b/crates/bitwarden/src/tool/generators/password.rs index 0a3874082..237394f56 100644 --- a/crates/bitwarden/src/tool/generators/password.rs +++ b/crates/bitwarden/src/tool/generators/password.rs @@ -27,23 +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()) } - -pub(super) fn passphrase(_input: PassphraseGeneratorRequest) -> Result { - Ok("correct-horse-battery-staple".to_string()) -} diff --git a/crates/bw/src/main.rs b/crates/bw/src/main.rs index 609650ab1..c4664c7f4 100644 --- a/crates/bw/src/main.rs +++ b/crates/bw/src/main.rs @@ -1,5 +1,7 @@ use bitwarden::{ - auth::RegisterRequest, client::client_settings::ClientSettings, tool::PasswordGeneratorRequest, + auth::RegisterRequest, + client::client_settings::ClientSettings, + tool::{PassphraseGeneratorRequest, PasswordGeneratorRequest}, }; use bitwarden_cli::{install_color_eyre, text_prompt_when_none, Color}; use clap::{command, Args, CommandFactory, Parser, Subcommand}; @@ -87,7 +89,7 @@ enum ItemCommands { #[derive(Subcommand, Clone)] enum GeneratorCommands { Password(PasswordGeneratorArgs), - Passphrase {}, + Passphrase(PassphraseGeneratorArgs), } #[derive(Args, Clone)] @@ -113,6 +115,18 @@ struct PasswordGeneratorArgs { length: u8, } +#[derive(Args, Clone)] +struct PassphraseGeneratorArgs { + #[arg(long, default_value = "3", help = "Number of words in the passphrase")] + words: u8, + #[arg(long, default_value = " ", help = "Separator between words")] + separator: char, + #[arg(long, action, help = "Capitalize the first letter of each word")] + capitalize: bool, + #[arg(long, action, help = "Include a number in one of the words")] + include_number: bool, +} + #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); @@ -206,7 +220,19 @@ async fn process_commands() -> Result<()> { println!("{}", password); } - GeneratorCommands::Passphrase {} => todo!(), + GeneratorCommands::Passphrase(args) => { + let passphrase = client + .generator() + .passphrase(PassphraseGeneratorRequest { + num_words: args.words, + word_separator: args.separator.to_string(), + capitalize: args.capitalize, + include_number: args.include_number, + }) + .await?; + + println!("{}", passphrase); + } }, }; diff --git a/languages/kotlin/doc.md b/languages/kotlin/doc.md index 652e09813..a84397719 100644 --- a/languages/kotlin/doc.md +++ b/languages/kotlin/doc.md @@ -1120,23 +1120,23 @@ implementations. numWords - integer,null - + integer + Number of words in the generated passphrase. This value must be between 3 and 20. wordSeparator - string,null - + string + Character separator between words in the generated passphrase. If the value is set, it cannot be empty. capitalize - boolean,null - + boolean + When set to true, capitalize the first letter of each word in the generated passphrase. includeNumber - boolean,null - + boolean + When set to true, include a number at the end of one of the words in the generated passphrase.