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-3434] Password generator #261

Merged
merged 19 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/bitwarden/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,6 @@ bitwarden-api-identity = { path = "../bitwarden-api-identity", version = "=0.2.1
bitwarden-api-api = { path = "../bitwarden-api-api", version = "=0.2.1" }

[dev-dependencies]
rand_chacha = "0.3.1"
tokio = { version = "1.28.2", features = ["rt", "macros"] }
wiremock = "0.5.18"
24 changes: 24 additions & 0 deletions crates/bitwarden/src/tool/generators/client_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ pub struct ClientGenerator<'a> {
}

impl<'a> ClientGenerator<'a> {
/// Generates a random password.
/// A passphrase is a combination of random words separated by a character.
/// An example of passphrase is `correct horse battery staple`.
///
/// By default, the password contains lowercase 16 characters, but the character
/// sets and password length can be customized using the `input` parameter.
///
/// # Examples
///
/// ```
/// use bitwarden::{Client, tool::PasswordGeneratorRequest, error::Result};
/// async fn test() -> Result<()> {
/// let input = PasswordGeneratorRequest {
/// lowercase: true,
/// uppercase: true,
/// numbers: true,
/// length: Some(20),
/// ..Default::default()
/// };
/// let password = Client::new(None).generator().password(input).await.unwrap();
/// println!("{}", password);
/// Ok(())
/// }
/// ```
pub async fn password(&self, input: PasswordGeneratorRequest) -> Result<String> {
password(input)
}
Expand Down
299 changes: 290 additions & 9 deletions crates/bitwarden/src/tool/generators/password.rs
Original file line number Diff line number Diff line change
@@ -1,30 +1,69 @@
use crate::error::Result;
use crate::error::{Error, Result};
use rand::{seq::SliceRandom, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

/// Password generator request. If all options are false, the default is to
/// Password generator request options. If all options are false, the default is to
/// generate a password with:
/// - lowercase
/// - uppercase
/// - numbers
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
///
/// The default length is 16.
#[derive(Serialize, Deserialize, Debug, JsonSchema, Default)]
#[derive(Serialize, Deserialize, Debug, JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "mobile", derive(uniffi::Record))]
pub struct PasswordGeneratorRequest {
/// When set to true, the generated password will contain lowercase characters (a-z).
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
pub lowercase: bool,
/// When set to true, the generated password will contain uppercase characters (A-Z).
pub uppercase: bool,
/// When set to true, the generated password will contain numbers (0-9).
pub numbers: bool,
/// When set to true, the generated password will contain special characters.
/// The supported characters are: ! @ # $ % ^ & *
pub special: bool,

/// The length of the generated password.
/// Note that the password length must be greater than the sum of all the minimums.
/// The default value when unset is 16.
pub length: Option<u8>,

/// When set to true, the generated password will not contain ambiguous characters.
/// The ambiguous characters are: I, O, l, 0, 1
pub avoid_ambiguous: Option<bool>, // TODO: Should we rename this to include_all_characters?
pub min_lowercase: Option<bool>,
pub min_uppercase: Option<bool>,
pub min_number: Option<bool>,
pub min_special: Option<bool>,

/// The minimum number of lowercase characters in the generated password.
/// When set, the value must be between 1 and 9. This value is ignored is lowercase is false
pub min_lowercase: Option<u8>,
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
/// The minimum number of uppercase characters in the generated password.
/// When set, the value must be between 1 and 9. This value is ignored is uppercase is false
pub min_uppercase: Option<u8>,
/// The minimum number of numbers in the generated password.
/// When set, the value must be between 1 and 9. This value is ignored is numbers is false
pub min_number: Option<u8>,
/// The minimum number of special characters in the generated password.
/// When set, the value must be between 1 and 9. This value is ignored is special is false
pub min_special: Option<u8>,
}

// We need to implement this manually so we can set one character set to true.
// Otherwise the default implementation will fail to generate a password.
impl Default for PasswordGeneratorRequest {
fn default() -> Self {
Self {
lowercase: true,
uppercase: false,
numbers: false,
special: false,
length: None,
avoid_ambiguous: None,
min_lowercase: None,
min_uppercase: None,
min_number: None,
min_special: None,
}
}
}

/// Passphrase generator request.
Expand All @@ -40,10 +79,252 @@ pub struct PassphraseGeneratorRequest {
pub include_number: Option<bool>,
}

pub(super) fn password(_input: PasswordGeneratorRequest) -> Result<String> {
Ok("pa11w0rd".to_string())
const DEFAULT_PASSWORD_LENGTH: u8 = 16;

const UPPER_CHARS_AMBIGUOUS: &[char] = &['I', 'O'];
const UPPER_CHARS: &[char] = &[
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U',
'V', 'W', 'X', 'Y', 'Z',
];
const LOWER_CHARS_AMBIGUOUS: &[char] = &['l'];
const LOWER_CHARS: &[char] = &[
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z',
];
const NUMBER_CHARS_AMBIGUOUS: &[char] = &['0', '1'];
const NUMBER_CHARS: &[char] = &['2', '3', '4', '5', '6', '7', '8', '9'];
const SPECIAL_CHARS: &[char] = &['!', '@', '#', '$', '%', '^', '&', '*'];
audreyality marked this conversation as resolved.
Show resolved Hide resolved

struct PasswordGeneratorCharSet {
lower: Vec<char>,
upper: Vec<char>,
number: Vec<char>,
special: Vec<char>,
all: Vec<char>,
}

impl PasswordGeneratorCharSet {
fn new(lower: bool, upper: bool, number: bool, special: bool, avoid_ambiguous: bool) -> Self {
fn chars(
enabled: bool,
chars: &[char],
ambiguous: &[char],
avoid_ambiguous: bool,
) -> Vec<char> {
if !enabled {
return Vec::new();
}
let mut chars = chars.to_vec();
if !avoid_ambiguous {
chars.extend_from_slice(ambiguous);
}
chars
}
let lower = chars(lower, LOWER_CHARS, LOWER_CHARS_AMBIGUOUS, avoid_ambiguous);
let upper = chars(upper, UPPER_CHARS, UPPER_CHARS_AMBIGUOUS, avoid_ambiguous);
let number = chars(
number,
NUMBER_CHARS,
NUMBER_CHARS_AMBIGUOUS,
avoid_ambiguous,
);
let special = chars(special, SPECIAL_CHARS, &[], avoid_ambiguous);
let all = lower
.iter()
.chain(&upper)
.chain(&number)
.chain(&special)
.copied()
.collect();
audreyality marked this conversation as resolved.
Show resolved Hide resolved

Self {
lower,
upper,
number,
special,
all,
}
}
}

/// Implementation of the random password generator. This is not accessible to the public API.
/// See [`ClientGenerator::password`](crate::ClientGenerator::password) for the API function.
pub(super) fn password(input: PasswordGeneratorRequest) -> Result<String> {
password_with_rng(rand::thread_rng(), input)
}

pub(super) fn password_with_rng(
mut rng: impl RngCore,
input: PasswordGeneratorRequest,
) -> Result<String> {
// We always have to have at least one character set enabled
if !input.lowercase && !input.uppercase && !input.numbers && !input.special {
return Err(Error::Internal(
"At least one character set must be enabled",
));
}

// Generate all character dictionaries
let chars = PasswordGeneratorCharSet::new(
input.lowercase,
input.uppercase,
input.numbers,
input.special,
input.avoid_ambiguous.unwrap_or(false),
);

// Make sure the minimum values are zero when the character
// set is disabled, and at least one when it's enabled
fn get_minimum(min: Option<u8>, enabled: bool) -> u8 {
if enabled {
u8::max(min.unwrap_or(1), 1)
} else {
0
}
}
let min_lowercase = get_minimum(input.min_lowercase, input.lowercase);
let min_uppercase = get_minimum(input.min_uppercase, input.uppercase);
let min_number = get_minimum(input.min_number, input.numbers);
let min_special = get_minimum(input.min_special, input.special);

// Check that the minimum lengths aren't larger than the password length
let min_length = min_lowercase + min_uppercase + min_number + min_special;
let length = input.length.unwrap_or(DEFAULT_PASSWORD_LENGTH);
if min_length > length {
return Err(Error::Internal(
"Password length can't be less than the sum of the minimums",
));
}
audreyality marked this conversation as resolved.
Show resolved Hide resolved

// Generate the minimum chars of each type, then generate the rest to fill the expected length
let mut buf = Vec::with_capacity(length as usize);

for _ in 0..min_lowercase {
buf.push(*chars.lower.choose(&mut rng).expect("slice is not empty"));
}
for _ in 0..min_uppercase {
buf.push(*chars.upper.choose(&mut rng).expect("slice is not empty"));
}
for _ in 0..min_number {
buf.push(*chars.number.choose(&mut rng).expect("slice is not empty"));
}
for _ in 0..min_special {
buf.push(*chars.special.choose(&mut rng).expect("slice is not empty"));
}
for _ in min_length..length {
buf.push(*chars.all.choose(&mut rng).expect("slice is not empty"));
}

buf.shuffle(&mut rng);
Ok(buf.iter().collect())
audreyality marked this conversation as resolved.
Show resolved Hide resolved
}

pub(super) fn passphrase(_input: PassphraseGeneratorRequest) -> Result<String> {
Ok("correct-horse-battery-staple".to_string())
}

#[cfg(test)]
mod test {
use std::collections::HashSet;

use rand::SeedableRng;

use super::*;

// We convert the slices to HashSets to be able to use `is_subset`
fn to_set(chars: &[char]) -> HashSet<char> {
chars.iter().copied().collect()
}

#[test]
fn test_password_characters_all() {
audreyality marked this conversation as resolved.
Show resolved Hide resolved
let set = PasswordGeneratorCharSet::new(true, true, true, true, true);
assert_eq!(set.lower, LOWER_CHARS);
assert_eq!(set.upper, UPPER_CHARS);
assert_eq!(set.number, NUMBER_CHARS);
assert_eq!(set.special, SPECIAL_CHARS);
}
#[test]
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
fn test_password_characters_all_ambiguous() {
let set = PasswordGeneratorCharSet::new(true, true, true, true, false);
assert!(to_set(&set.lower).is_superset(&to_set(LOWER_CHARS)));
assert!(to_set(&set.lower).is_superset(&to_set(LOWER_CHARS_AMBIGUOUS)));
assert!(to_set(&set.upper).is_superset(&to_set(UPPER_CHARS)));
assert!(to_set(&set.upper).is_superset(&to_set(UPPER_CHARS_AMBIGUOUS)));
assert!(to_set(&set.number).is_superset(&to_set(NUMBER_CHARS)));
assert!(to_set(&set.number).is_superset(&to_set(NUMBER_CHARS_AMBIGUOUS)));
assert_eq!(set.special, SPECIAL_CHARS);
}
#[test]
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
fn test_password_characters_lower() {
let set = PasswordGeneratorCharSet::new(true, false, false, false, true);
assert_eq!(set.lower, LOWER_CHARS);
assert_eq!(set.upper, Vec::new());
assert_eq!(set.number, Vec::new());
assert_eq!(set.special, Vec::new());
}
#[test]
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
fn test_password_characters_upper_ambiguous() {
// Only uppercase including ambiguous
let set = PasswordGeneratorCharSet::new(false, true, false, false, false);
assert_eq!(set.lower, Vec::new());
assert!(to_set(&set.upper).is_superset(&to_set(UPPER_CHARS)));
assert!(to_set(&set.upper).is_superset(&to_set(UPPER_CHARS_AMBIGUOUS)));
assert_eq!(set.number, Vec::new());
assert_eq!(set.special, Vec::new());
}

#[test]
fn test_password_gen() {
let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved

let pass = password_with_rng(
&mut rng,
PasswordGeneratorRequest {
lowercase: true,
uppercase: true,
numbers: true,
special: true,
..Default::default()
},
)
.unwrap();
assert_eq!(pass, "xfZPr&wXCiFta8DM");

let pass = password_with_rng(
&mut rng,
PasswordGeneratorRequest {
lowercase: true,
uppercase: true,
numbers: false,
special: false,
length: Some(20),
avoid_ambiguous: Some(false),
min_lowercase: Some(1),
min_uppercase: Some(1),
min_number: None,
min_special: None,
},
)
.unwrap();
assert_eq!(pass, "jvpFStaIdRUoENAeTmJw");

let pass = password_with_rng(
&mut rng,
PasswordGeneratorRequest {
lowercase: false,
uppercase: false,
numbers: true,
special: true,
length: Some(5),
avoid_ambiguous: Some(true),
min_lowercase: None,
min_uppercase: None,
min_number: Some(3),
min_special: Some(2),
},
)
.unwrap();
assert_eq!(pass, "^878%");
}
}