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