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