Skip to main content

bitwarden_generators/
password.rs

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