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#[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 pub num_words: u8,
27 pub word_separator: String,
29 pub capitalize: bool,
31 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
47pub const MINIMUM_PASSPHRASE_NUM_WORDS: u8 = 3;
49pub const MAXIMUM_PASSPHRASE_NUM_WORDS: u8 = 20;
51
52struct 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 fn validate_options(self) -> Result<ValidPassphraseGeneratorOptions, PassphraseError> {
66 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
85pub(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 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 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 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}