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#[allow(missing_docs)]
13#[bitwarden_error(flat)]
14#[derive(Debug, Error)]
15pub enum PassphraseError {
16    #[error("'num_words' must be between {} and {}", minimum, maximum)]
17    InvalidNumWords { minimum: u8, maximum: u8 },
18}
19
20/// Passphrase 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 PassphraseGeneratorRequest {
26    /// Number of words in the generated passphrase.
27    /// This value must be between 3 and 20.
28    pub num_words: u8,
29    /// Character separator between words in the generated passphrase. The value cannot be empty.
30    pub word_separator: String,
31    /// When set to true, capitalize the first letter of each word in the generated passphrase.
32    pub capitalize: bool,
33    /// When set to true, include a number at the end of one of the words in the generated
34    /// passphrase.
35    pub include_number: bool,
36}
37
38impl Default for PassphraseGeneratorRequest {
39    fn default() -> Self {
40        Self {
41            num_words: 3,
42            word_separator: ' '.to_string(),
43            capitalize: false,
44            include_number: false,
45        }
46    }
47}
48
49const MINIMUM_PASSPHRASE_NUM_WORDS: u8 = 3;
50const 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(rand::thread_rng(), options))
89}
90
91fn passphrase_with_rng(mut rng: impl RngCore, 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 RngCore, num_words: u8) -> Vec<String> {
103    (0..num_words)
104        .map(|_| {
105            EFF_LONG_WORD_LIST
106                .choose(&mut rng)
107                .expect("slice is not empty")
108                .to_string()
109        })
110        .collect()
111}
112
113fn include_number_in_words(mut rng: impl RngCore, words: &mut [String]) {
114    let number_idx = rng.gen_range(0..words.len());
115    words[number_idx].push_str(&rng.gen_range(0..=9).to_string());
116}
117
118fn capitalize_words(words: &mut [String]) {
119    words
120        .iter_mut()
121        .for_each(|w| *w = capitalize_first_letter(w));
122}
123
124#[cfg(test)]
125mod tests {
126    use rand::SeedableRng;
127
128    use super::*;
129
130    #[test]
131    fn test_gen_words() {
132        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
133        assert_eq!(
134            &gen_words(&mut rng, 4),
135            &["subsystem", "undertook", "silenced", "dinginess"]
136        );
137        assert_eq!(&gen_words(&mut rng, 1), &["numbing"]);
138        assert_eq!(&gen_words(&mut rng, 2), &["catnip", "jokester"]);
139    }
140
141    #[test]
142    fn test_capitalize() {
143        assert_eq!(capitalize_first_letter("hello"), "Hello");
144        assert_eq!(capitalize_first_letter("1ello"), "1ello");
145        assert_eq!(capitalize_first_letter("Hello"), "Hello");
146        assert_eq!(capitalize_first_letter("h"), "H");
147        assert_eq!(capitalize_first_letter(""), "");
148
149        // Also supports non-ascii, though the EFF list doesn't have any
150        assert_eq!(capitalize_first_letter("áéíóú"), "Áéíóú");
151    }
152
153    #[test]
154    fn test_capitalize_words() {
155        let mut words = vec!["hello".into(), "world".into()];
156        capitalize_words(&mut words);
157        assert_eq!(words, &["Hello", "World"]);
158    }
159
160    #[test]
161    fn test_include_number() {
162        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
163
164        let mut words = vec!["hello".into(), "world".into()];
165        include_number_in_words(&mut rng, &mut words);
166        assert_eq!(words, &["hello", "world7"]);
167
168        let mut words = vec!["This".into(), "is".into(), "a".into(), "test".into()];
169        include_number_in_words(&mut rng, &mut words);
170        assert_eq!(words, &["This", "is", "a1", "test"]);
171    }
172
173    #[test]
174    fn test_separator() {
175        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
176
177        let input = PassphraseGeneratorRequest {
178            num_words: 4,
179            // This emoji is 35 bytes long, but represented as a single character
180            word_separator: "👨🏻‍❤️‍💋‍👨🏻".into(),
181            capitalize: false,
182            include_number: true,
183        }
184        .validate_options()
185        .unwrap();
186        assert_eq!(
187            passphrase_with_rng(&mut rng, input),
188            "subsystem4👨🏻‍❤️‍💋‍👨🏻undertook👨🏻‍❤️‍💋‍👨🏻silenced👨🏻‍❤️‍💋‍👨🏻dinginess"
189        );
190    }
191
192    #[test]
193    fn test_passphrase() {
194        let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
195
196        let input = PassphraseGeneratorRequest {
197            num_words: 4,
198            word_separator: "-".into(),
199            capitalize: true,
200            include_number: true,
201        }
202        .validate_options()
203        .unwrap();
204        assert_eq!(
205            passphrase_with_rng(&mut rng, input),
206            "Subsystem4-Undertook-Silenced-Dinginess"
207        );
208
209        let input = PassphraseGeneratorRequest {
210            num_words: 3,
211            word_separator: " ".into(),
212            capitalize: false,
213            include_number: true,
214        }
215        .validate_options()
216        .unwrap();
217        assert_eq!(
218            passphrase_with_rng(&mut rng, input),
219            "drew7 hankering cabana"
220        );
221
222        let input = PassphraseGeneratorRequest {
223            num_words: 5,
224            word_separator: ";".into(),
225            capitalize: false,
226            include_number: false,
227        }
228        .validate_options()
229        .unwrap();
230        assert_eq!(
231            passphrase_with_rng(&mut rng, input),
232            "duller;backlight;factual;husked;remover"
233        );
234    }
235}