Skip to main content

bitwarden_generators/
passphrase.rs

1use bitwarden_crypto::EFF_LONG_WORD_LIST;
2use bitwarden_error::bitwarden_error;
3use rand::{Rng, RngExt, seq::IndexedRandom};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::util::capitalize_first_letter;
9
10#[allow(missing_docs)]
11#[bitwarden_error(full)]
12#[derive(Debug, Error)]
13pub enum PassphraseError {
14    #[error("'num_words' must be between {} and {}", minimum, maximum)]
15    InvalidNumWords { minimum: u8, maximum: u8 },
16}
17
18/// Passphrase generator request options.
19#[derive(Serialize, Deserialize, Debug, JsonSchema)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
22#[bitwarden_ffi::wasm_record]
23pub struct PassphraseGeneratorRequest {
24    /// Number of words in the generated passphrase.
25    /// This value must be between 3 and 20.
26    pub num_words: u8,
27    /// Character separator between words in the generated passphrase. The value cannot be empty.
28    pub word_separator: String,
29    /// When set to true, capitalize the first letter of each word in the generated passphrase.
30    pub capitalize: bool,
31    /// When set to true, include a number at the end of one of the words in the generated
32    /// passphrase.
33    pub include_number: bool,
34}
35
36impl Default for PassphraseGeneratorRequest {
37    fn default() -> Self {
38        Self {
39            num_words: 3,
40            word_separator: ' '.to_string(),
41            capitalize: false,
42            include_number: false,
43        }
44    }
45}
46
47/// Minimum number of words allowed in a generated passphrase.
48pub const MINIMUM_PASSPHRASE_NUM_WORDS: u8 = 3;
49/// Maximum number of words allowed in a generated passphrase.
50pub const MAXIMUM_PASSPHRASE_NUM_WORDS: u8 = 20;
51
52/// Represents a set of valid options to generate a passhprase with.
53/// To get an instance of it, use
54/// [`PassphraseGeneratorRequest::validate_options`](PassphraseGeneratorRequest::validate_options)
55struct ValidPassphraseGeneratorOptions {
56    pub(super) num_words: u8,
57    pub(super) word_separator: String,
58    pub(super) capitalize: bool,
59    pub(super) include_number: bool,
60}
61
62impl PassphraseGeneratorRequest {
63    /// Validates the request and returns an immutable struct with valid options to use with the
64    /// passphrase generator.
65    fn validate_options(self) -> Result<ValidPassphraseGeneratorOptions, PassphraseError> {
66        // TODO: Add password generator policy checks
67
68        if !(MINIMUM_PASSPHRASE_NUM_WORDS..=MAXIMUM_PASSPHRASE_NUM_WORDS).contains(&self.num_words)
69        {
70            return Err(PassphraseError::InvalidNumWords {
71                minimum: MINIMUM_PASSPHRASE_NUM_WORDS,
72                maximum: MAXIMUM_PASSPHRASE_NUM_WORDS,
73            });
74        }
75
76        Ok(ValidPassphraseGeneratorOptions {
77            num_words: self.num_words,
78            word_separator: self.word_separator,
79            capitalize: self.capitalize,
80            include_number: self.include_number,
81        })
82    }
83}
84
85/// Implementation of the random passphrase generator.
86pub(crate) fn passphrase(request: PassphraseGeneratorRequest) -> Result<String, PassphraseError> {
87    let options = request.validate_options()?;
88    Ok(passphrase_with_rng(bitwarden_random::rng(), options))
89}
90
91fn passphrase_with_rng(mut rng: impl Rng, options: ValidPassphraseGeneratorOptions) -> String {
92    let mut passphrase_words = gen_words(&mut rng, options.num_words);
93    if options.include_number {
94        include_number_in_words(&mut rng, &mut passphrase_words);
95    }
96    if options.capitalize {
97        capitalize_words(&mut passphrase_words);
98    }
99    passphrase_words.join(&options.word_separator)
100}
101
102fn gen_words(mut rng: impl Rng, num_words: u8) -> Vec<String> {
103    // A passphrase consists of words joined by hyphens. The `EFF_LONG_WORD_LIST` contains some
104    // words that contain hyphens, which creates ambiguous encodings with more
105    // hyphen-separated-segments than expected.
106    let words: Vec<_> = EFF_LONG_WORD_LIST
107        .iter()
108        .filter(|w| !w.contains('-'))
109        .collect();
110
111    (0..num_words)
112        .map(|_| {
113            words
114                .choose(&mut rng)
115                .expect("slice is not empty")
116                .to_string()
117        })
118        .collect()
119}
120
121fn include_number_in_words(mut rng: impl Rng, words: &mut [String]) {
122    let number_idx = rng.random_range(0..words.len());
123    words[number_idx].push_str(&rng.random_range(0..=9).to_string());
124}
125
126fn capitalize_words(words: &mut [String]) {
127    words
128        .iter_mut()
129        .for_each(|w| *w = capitalize_first_letter(w));
130}
131
132#[cfg(test)]
133mod tests {
134    use rand::SeedableRng;
135
136    use super::*;
137
138    #[test]
139    fn test_gen_words() {
140        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
141        assert_eq!(
142            &gen_words(&mut rng, 4),
143            &["crust", "substance", "undertook", "protector"]
144        );
145        assert_eq!(&gen_words(&mut rng, 1), &["sighing"]);
146        assert_eq!(&gen_words(&mut rng, 2), &["dinghy", "numbing"]);
147    }
148
149    #[test]
150    fn test_capitalize() {
151        assert_eq!(capitalize_first_letter("hello"), "Hello");
152        assert_eq!(capitalize_first_letter("1ello"), "1ello");
153        assert_eq!(capitalize_first_letter("Hello"), "Hello");
154        assert_eq!(capitalize_first_letter("h"), "H");
155        assert_eq!(capitalize_first_letter(""), "");
156
157        // Also supports non-ascii, though the EFF list doesn't have any
158        assert_eq!(capitalize_first_letter("áéíóú"), "Áéíóú");
159    }
160
161    #[test]
162    fn test_capitalize_words() {
163        let mut words = vec!["hello".into(), "world".into()];
164        capitalize_words(&mut words);
165        assert_eq!(words, &["Hello", "World"]);
166    }
167
168    #[test]
169    fn test_include_number() {
170        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
171
172        let mut words = vec!["hello".into(), "world".into()];
173        include_number_in_words(&mut rng, &mut words);
174        assert_eq!(words, &["hello8", "world"]);
175
176        let mut words = vec!["This".into(), "is".into(), "a".into(), "test".into()];
177        include_number_in_words(&mut rng, &mut words);
178        assert_eq!(words, &["This", "is", "a", "test6"]);
179    }
180
181    #[test]
182    fn test_separator() {
183        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
184
185        let input = PassphraseGeneratorRequest {
186            num_words: 4,
187            // This emoji is 35 bytes long, but represented as a single character
188            word_separator: "👨🏻‍❤️‍💋‍👨🏻".into(),
189            capitalize: false,
190            include_number: true,
191        }
192        .validate_options()
193        .unwrap();
194        assert_eq!(
195            passphrase_with_rng(&mut rng, input),
196            "crust👨🏻‍❤️‍💋‍👨🏻substance👨🏻‍❤️‍💋‍👨🏻undertook👨🏻‍❤️‍💋‍👨🏻protector2"
197        );
198    }
199
200    #[test]
201    fn test_passphrase() {
202        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
203
204        let input = PassphraseGeneratorRequest {
205            num_words: 4,
206            word_separator: "-".into(),
207            capitalize: true,
208            include_number: true,
209        }
210        .validate_options()
211        .unwrap();
212        assert_eq!(
213            passphrase_with_rng(&mut rng, input),
214            "Crust-Substance-Undertook-Protector2"
215        );
216
217        let input = PassphraseGeneratorRequest {
218            num_words: 3,
219            word_separator: " ".into(),
220            capitalize: false,
221            include_number: true,
222        }
223        .validate_options()
224        .unwrap();
225        assert_eq!(
226            passphrase_with_rng(&mut rng, input),
227            "numbing4 catnap jokester"
228        );
229
230        let input = PassphraseGeneratorRequest {
231            num_words: 5,
232            word_separator: ";".into(),
233            capitalize: false,
234            include_number: false,
235        }
236        .validate_options()
237        .unwrap();
238        assert_eq!(
239            passphrase_with_rng(&mut rng, input),
240            "cabana;pungent;acts;sappy;duller"
241        );
242    }
243}