bitwarden_generators/
passphrase.rs

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