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 7 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"
254 changes: 247 additions & 7 deletions crates/bitwarden/src/tool/generators/password.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::error::Result;
use crate::error::{Error, Result};
use rand::{seq::SliceRandom, RngCore};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

Expand All @@ -21,10 +22,10 @@ pub struct PasswordGeneratorRequest {
pub length: Option<u8>,

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>,
pub min_lowercase: Option<u8>,
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
pub min_uppercase: Option<u8>,
pub min_number: Option<u8>,
pub min_special: Option<u8>,
dani-garcia marked this conversation as resolved.
Show resolved Hide resolved
}

/// Passphrase generator request.
Expand All @@ -40,10 +41,249 @@ 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,
}
}
}

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 rand::SeedableRng;

use super::*;

// We convert the slices to Strings to be able to use `contains`
// This wouldn't work if the character sets were ordered differently, but that's not the case for us
fn to_string(chars: &[char]) -> String {
chars.iter().collect()
}
audreyality marked this conversation as resolved.
Show resolved Hide resolved

#[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_string(&set.lower).contains(&to_string(LOWER_CHARS)));
assert!(to_string(&set.lower).contains(&to_string(LOWER_CHARS_AMBIGUOUS)));
assert!(to_string(&set.upper).contains(&to_string(UPPER_CHARS)));
assert!(to_string(&set.upper).contains(&to_string(UPPER_CHARS_AMBIGUOUS)));
assert!(to_string(&set.number).contains(&to_string(NUMBER_CHARS)));
assert!(to_string(&set.number).contains(&to_string(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_string(&set.upper).contains(&to_string(UPPER_CHARS)));
assert!(to_string(&set.upper).contains(&to_string(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%");
}
}