Skip to content

Commit

Permalink
[PM-3435] Passphrase generator (#262)
Browse files Browse the repository at this point in the history
## 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 <[email protected]>
  • Loading branch information
dani-garcia and Hinton authored Dec 4, 2023
1 parent 48aeb58 commit 7fc3b84
Show file tree
Hide file tree
Showing 6 changed files with 299 additions and 32 deletions.
26 changes: 23 additions & 3 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,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<String> {
passphrase(input)
}
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;
236 changes: 236 additions & 0 deletions crates/bitwarden/src/tool/generators/passphrase.rs
Original file line number Diff line number Diff line change
@@ -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<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(request: PassphraseGeneratorRequest) -> Result<String> {
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<String> {
(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::<String>() + 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"
);
}
}
17 changes: 0 additions & 17 deletions crates/bitwarden/src/tool/generators/password.rs
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
16 changes: 8 additions & 8 deletions languages/kotlin/doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -1120,23 +1120,23 @@ implementations.
</tr>
<tr>
<th>numWords</th>
<th>integer,null</th>
<th></th>
<th>integer</th>
<th>Number of words in the generated passphrase. This value must be between 3 and 20.</th>
</tr>
<tr>
<th>wordSeparator</th>
<th>string,null</th>
<th></th>
<th>string</th>
<th>Character separator between words in the generated passphrase. If the value is set, it cannot be empty.</th>
</tr>
<tr>
<th>capitalize</th>
<th>boolean,null</th>
<th></th>
<th>boolean</th>
<th>When set to true, capitalize the first letter of each word in the generated passphrase.</th>
</tr>
<tr>
<th>includeNumber</th>
<th>boolean,null</th>
<th></th>
<th>boolean</th>
<th>When set to true, include a number at the end of one of the words in the generated passphrase.</th>
</tr>
</table>

Expand Down

0 comments on commit 7fc3b84

Please sign in to comment.