bitwarden_generators/
password.rs

1use std::collections::BTreeSet;
2
3use bitwarden_error::bitwarden_error;
4use rand::{distributions::Distribution, seq::SliceRandom, RngCore};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8#[cfg(feature = "wasm")]
9use tsify_next::Tsify;
10
11#[allow(missing_docs)]
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum PasswordError {
15    #[error("No character set enabled")]
16    NoCharacterSetEnabled,
17    #[error("Invalid password length")]
18    InvalidLength,
19}
20
21/// Password generator request options.
22#[derive(Serialize, Deserialize, Debug, JsonSchema)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
25#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
26pub struct PasswordGeneratorRequest {
27    /// Include lowercase characters (a-z).
28    pub lowercase: bool,
29    /// Include uppercase characters (A-Z).
30    pub uppercase: bool,
31    /// Include numbers (0-9).
32    pub numbers: bool,
33    /// Include special characters: ! @ # $ % ^ & *
34    pub special: bool,
35
36    /// The length of the generated password.
37    /// Note that the password length must be greater than the sum of all the minimums.
38    pub length: u8,
39
40    /// When set to true, the generated password will not contain ambiguous characters.
41    /// The ambiguous characters are: I, O, l, 0, 1
42    pub avoid_ambiguous: bool, // TODO: Should we rename this to include_all_characters?
43
44    /// The minimum number of lowercase characters in the generated password.
45    /// When set, the value must be between 1 and 9. This value is ignored if lowercase is false.
46    pub min_lowercase: Option<u8>,
47    /// The minimum number of uppercase characters in the generated password.
48    /// When set, the value must be between 1 and 9. This value is ignored if uppercase is false.
49    pub min_uppercase: Option<u8>,
50    /// The minimum number of numbers in the generated password.
51    /// When set, the value must be between 1 and 9. This value is ignored if numbers is false.
52    pub min_number: Option<u8>,
53    /// The minimum number of special characters in the generated password.
54    /// When set, the value must be between 1 and 9. This value is ignored if special is false.
55    pub min_special: Option<u8>,
56}
57
58const DEFAULT_PASSWORD_LENGTH: u8 = 16;
59
60impl Default for PasswordGeneratorRequest {
61    fn default() -> Self {
62        Self {
63            lowercase: true,
64            uppercase: true,
65            numbers: true,
66            special: false,
67            length: DEFAULT_PASSWORD_LENGTH,
68            avoid_ambiguous: false,
69            min_lowercase: None,
70            min_uppercase: None,
71            min_number: None,
72            min_special: None,
73        }
74    }
75}
76
77const UPPER_CHARS_AMBIGUOUS: &[char] = &['I', 'O'];
78const LOWER_CHARS_AMBIGUOUS: &[char] = &['l'];
79const NUMBER_CHARS_AMBIGUOUS: &[char] = &['0', '1'];
80const SPECIAL_CHARS: &[char] = &['!', '@', '#', '$', '%', '^', '&', '*'];
81
82/// A set of characters used to generate a password. This set is backed by a BTreeSet
83/// to have consistent ordering between runs. This is not important during normal execution,
84/// but it's necessary for the tests to be repeatable.
85/// To create an instance, use [`CharSet::default()`](CharSet::default)
86#[derive(Clone, Default)]
87struct CharSet(BTreeSet<char>);
88impl CharSet {
89    /// Includes the given characters in the set. Any duplicate items will be ignored
90    pub fn include(self, other: impl IntoIterator<Item = char>) -> Self {
91        self.include_if(true, other)
92    }
93
94    /// Includes the given characters in the set if the predicate is true. Any duplicate items will
95    /// be ignored
96    pub fn include_if(mut self, predicate: bool, other: impl IntoIterator<Item = char>) -> Self {
97        if predicate {
98            self.0.extend(other);
99        }
100        self
101    }
102
103    /// Excludes the given characters from the set. Any missing items will be ignored
104    pub fn exclude_if<'a>(
105        self,
106        predicate: bool,
107        other: impl IntoIterator<Item = &'a char>,
108    ) -> Self {
109        if predicate {
110            let other: BTreeSet<_> = other.into_iter().copied().collect();
111            Self(self.0.difference(&other).copied().collect())
112        } else {
113            self
114        }
115    }
116}
117impl<'a> IntoIterator for &'a CharSet {
118    type Item = char;
119    type IntoIter = std::iter::Copied<std::collections::btree_set::Iter<'a, char>>;
120    fn into_iter(self) -> Self::IntoIter {
121        self.0.iter().copied()
122    }
123}
124impl Distribution<char> for CharSet {
125    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> char {
126        let idx = rng.gen_range(0..self.0.len());
127        *self.0.iter().nth(idx).expect("Valid index")
128    }
129}
130
131/// Represents a set of valid options to generate a password with.
132/// To get an instance of it, use
133/// [`PasswordGeneratorRequest::validate_options`](PasswordGeneratorRequest::validate_options)
134struct PasswordGeneratorOptions {
135    pub(super) lower: (CharSet, usize),
136    pub(super) upper: (CharSet, usize),
137    pub(super) number: (CharSet, usize),
138    pub(super) special: (CharSet, usize),
139    pub(super) all: (CharSet, usize),
140
141    pub(super) length: usize,
142}
143
144impl PasswordGeneratorRequest {
145    /// Validates the request and returns an immutable struct with valid options to use with the
146    /// password generator.
147    fn validate_options(self) -> Result<PasswordGeneratorOptions, PasswordError> {
148        // TODO: Add password generator policy checks
149
150        // We always have to have at least one character set enabled
151        if !self.lowercase && !self.uppercase && !self.numbers && !self.special {
152            return Err(PasswordError::NoCharacterSetEnabled);
153        }
154
155        if self.length < 4 {
156            return Err(PasswordError::InvalidLength);
157        }
158
159        // Make sure the minimum values are zero when the character
160        // set is disabled, and at least one when it's enabled
161        fn get_minimum(min: Option<u8>, enabled: bool) -> usize {
162            if enabled {
163                usize::max(min.unwrap_or(1) as usize, 1)
164            } else {
165                0
166            }
167        }
168
169        let length = self.length as usize;
170        let min_lowercase = get_minimum(self.min_lowercase, self.lowercase);
171        let min_uppercase = get_minimum(self.min_uppercase, self.uppercase);
172        let min_number = get_minimum(self.min_number, self.numbers);
173        let min_special = get_minimum(self.min_special, self.special);
174
175        // Check that the minimum lengths aren't larger than the password length
176        let minimum_length = min_lowercase + min_uppercase + min_number + min_special;
177        if minimum_length > length {
178            return Err(PasswordError::InvalidLength);
179        }
180
181        let lower = (
182            CharSet::default()
183                .include_if(self.lowercase, 'a'..='z')
184                .exclude_if(self.avoid_ambiguous, LOWER_CHARS_AMBIGUOUS),
185            min_lowercase,
186        );
187
188        let upper = (
189            CharSet::default()
190                .include_if(self.uppercase, 'A'..='Z')
191                .exclude_if(self.avoid_ambiguous, UPPER_CHARS_AMBIGUOUS),
192            min_uppercase,
193        );
194
195        let number = (
196            CharSet::default()
197                .include_if(self.numbers, '0'..='9')
198                .exclude_if(self.avoid_ambiguous, NUMBER_CHARS_AMBIGUOUS),
199            min_number,
200        );
201
202        let special = (
203            CharSet::default().include_if(self.special, SPECIAL_CHARS.iter().copied()),
204            min_special,
205        );
206
207        let all = (
208            CharSet::default()
209                .include(&lower.0)
210                .include(&upper.0)
211                .include(&number.0)
212                .include(&special.0),
213            length - minimum_length,
214        );
215
216        Ok(PasswordGeneratorOptions {
217            lower,
218            upper,
219            number,
220            special,
221            all,
222            length,
223        })
224    }
225}
226
227/// Implementation of the random password generator.
228pub(crate) fn password(input: PasswordGeneratorRequest) -> Result<String, PasswordError> {
229    let options = input.validate_options()?;
230    Ok(password_with_rng(rand::thread_rng(), options))
231}
232
233fn password_with_rng(mut rng: impl RngCore, options: PasswordGeneratorOptions) -> String {
234    let mut buf: Vec<char> = Vec::with_capacity(options.length);
235
236    let opts = [
237        &options.all,
238        &options.upper,
239        &options.lower,
240        &options.number,
241        &options.special,
242    ];
243    for (set, qty) in opts {
244        buf.extend(set.sample_iter(&mut rng).take(*qty));
245    }
246
247    buf.shuffle(&mut rng);
248
249    buf.iter().collect()
250}
251
252#[cfg(test)]
253mod test {
254    use std::collections::BTreeSet;
255
256    use rand::SeedableRng;
257
258    use super::*;
259
260    // We convert the slices to BTreeSets to be able to use `is_subset`
261    fn ref_to_set<'a>(chars: impl IntoIterator<Item = &'a char>) -> BTreeSet<char> {
262        chars.into_iter().copied().collect()
263    }
264    fn to_set(chars: impl IntoIterator<Item = char>) -> BTreeSet<char> {
265        chars.into_iter().collect()
266    }
267
268    #[test]
269    fn test_password_gen_all_charsets_enabled() {
270        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
271
272        let options = PasswordGeneratorRequest {
273            lowercase: true,
274            uppercase: true,
275            numbers: true,
276            special: true,
277            avoid_ambiguous: false,
278            ..Default::default()
279        }
280        .validate_options()
281        .unwrap();
282
283        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
284        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
285        assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
286        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
287
288        let pass = password_with_rng(&mut rng, options);
289        assert_eq!(pass, "Z!^B5r%hUa23dFM@");
290    }
291
292    #[test]
293    fn test_password_gen_only_letters_enabled() {
294        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
295
296        let options = PasswordGeneratorRequest {
297            lowercase: true,
298            uppercase: true,
299            numbers: false,
300            special: false,
301            avoid_ambiguous: false,
302            ..Default::default()
303        }
304        .validate_options()
305        .unwrap();
306
307        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
308        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
309        assert_eq!(to_set(&options.number.0), to_set([]));
310        assert_eq!(to_set(&options.special.0), to_set([]));
311
312        let pass = password_with_rng(&mut rng, options);
313        assert_eq!(pass, "NQiFrGufQMiNUAmj");
314    }
315
316    #[test]
317    fn test_password_gen_only_numbers_and_lower_enabled_no_ambiguous() {
318        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
319
320        let options = PasswordGeneratorRequest {
321            lowercase: true,
322            uppercase: false,
323            numbers: true,
324            special: false,
325            avoid_ambiguous: true,
326            ..Default::default()
327        }
328        .validate_options()
329        .unwrap();
330
331        assert!(to_set(&options.lower.0).is_subset(&to_set('a'..='z')));
332        assert!(to_set(&options.lower.0).is_disjoint(&ref_to_set(LOWER_CHARS_AMBIGUOUS)));
333
334        assert!(to_set(&options.number.0).is_subset(&to_set('0'..='9')));
335        assert!(to_set(&options.number.0).is_disjoint(&ref_to_set(NUMBER_CHARS_AMBIGUOUS)));
336
337        assert_eq!(to_set(&options.upper.0), to_set([]));
338        assert_eq!(to_set(&options.special.0), to_set([]));
339
340        let pass = password_with_rng(&mut rng, options);
341        assert_eq!(pass, "mnjabfz5ct272prf");
342    }
343
344    #[test]
345    fn test_password_gen_only_upper_and_special_enabled_no_ambiguous() {
346        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
347
348        let options = PasswordGeneratorRequest {
349            lowercase: false,
350            uppercase: true,
351            numbers: false,
352            special: true,
353            avoid_ambiguous: true,
354            ..Default::default()
355        }
356        .validate_options()
357        .unwrap();
358
359        assert!(to_set(&options.upper.0).is_subset(&to_set('A'..='Z')));
360        assert!(to_set(&options.upper.0).is_disjoint(&ref_to_set(UPPER_CHARS_AMBIGUOUS)));
361
362        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
363
364        assert_eq!(to_set(&options.lower.0), to_set([]));
365        assert_eq!(to_set(&options.number.0), to_set([]));
366
367        let pass = password_with_rng(&mut rng, options);
368        assert_eq!(pass, "B*GBQANS%UZPQD!K");
369    }
370
371    #[test]
372    fn test_password_gen_minimum_limits() {
373        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
374
375        let options = PasswordGeneratorRequest {
376            lowercase: true,
377            uppercase: true,
378            numbers: true,
379            special: true,
380            avoid_ambiguous: false,
381            length: 24,
382            min_lowercase: Some(5),
383            min_uppercase: Some(5),
384            min_number: Some(5),
385            min_special: Some(5),
386        }
387        .validate_options()
388        .unwrap();
389
390        assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
391        assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
392        assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
393        assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
394
395        assert_eq!(options.lower.1, 5);
396        assert_eq!(options.upper.1, 5);
397        assert_eq!(options.number.1, 5);
398        assert_eq!(options.special.1, 5);
399
400        let pass = password_with_rng(&mut rng, options);
401        assert_eq!(pass, "236q5!a#R%PG5rI%k1!*@uRt");
402    }
403}