Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-3435] Passphrase generator #262

Merged
merged 18 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions crates/bitwarden/src/tool/generators/client_generator.rs
Original file line number Diff line number Diff line change
@@ -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,
};

Expand All @@ -15,8 +14,30 @@ 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<String> {
passphrase(input)
let options = input.validate_options()?;
Ok(passphrase(options))
}
}

Expand Down
4 changes: 3 additions & 1 deletion crates/bitwarden/src/tool/generators/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
240 changes: 240 additions & 0 deletions crates/bitwarden/src/tool/generators/passphrase.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
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 {
audreyality marked this conversation as resolved.
Show resolved Hide resolved
/// 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,
Hinton marked this conversation as resolved.
Show resolved Hide resolved
/// 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;

// We don't want the validated struct to be accessible, yet at the same time it needs to be public
// to be used as a return type, so we define it in a private module to make it innaccessible.
mod private {
/// Represents a set of valid options to generate a passhprase with.
/// To get an instance of it, use [`PassphraseGeneratorRequest::validate_options`](PassphraseGeneratorRequest::validate_options)
pub struct ValidPassphraseGeneratorOptions {
pub(super) num_words: u8,
pub(super) word_separator: String,
pub(super) capitalize: bool,
pub(super) include_number: bool,
}
}
use private::ValidPassphraseGeneratorOptions;

impl PassphraseGeneratorRequest {
/// Validates the request and returns an immutable struct with valid options to use with the passphrase generator.
pub fn validate_options(self) -> Result<ValidPassphraseGeneratorOptions> {
// 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(options: ValidPassphraseGeneratorOptions) -> String {
passphrase_with_rng(rand::thread_rng(), options)
}
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved

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<String> {
(0..num_words)
.map(|_| {
EFF_LONG_WORD_LIST
.choose(&mut rng)
.expect("slice is not empty")
.to_string()
})
.collect()
}
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved

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.
audreyality marked this conversation as resolved.
Show resolved Hide resolved
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
audreyality marked this conversation as resolved.
Show resolved Hide resolved
}

#[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"
);
}
}
17 changes: 0 additions & 17 deletions crates/bitwarden/src/tool/generators/password.rs
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,6 @@ pub struct PasswordGeneratorRequest {
pub min_special: Option<bool>,
}

/// 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<u8>,
pub word_separator: Option<String>,
pub capitalize: Option<bool>,
pub include_number: Option<bool>,
}

pub(super) fn password(_input: PasswordGeneratorRequest) -> Result<String> {
Ok("pa11w0rd".to_string())
}

pub(super) fn passphrase(_input: PassphraseGeneratorRequest) -> Result<String> {
Ok("correct-horse-battery-staple".to_string())
}
32 changes: 29 additions & 3 deletions crates/bw/src/main.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -87,7 +89,7 @@ enum ItemCommands {
#[derive(Subcommand, Clone)]
enum GeneratorCommands {
Password(PasswordGeneratorArgs),
Passphrase {},
Passphrase(PassphraseGeneratorArgs),
}

#[derive(Args, Clone)]
Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
},
};

Expand Down
Loading