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#[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 pub num_words: u8,
28 pub word_separator: String,
30 pub capitalize: bool,
32 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
51struct 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 fn validate_options(self) -> Result<ValidPassphraseGeneratorOptions, PassphraseError> {
65 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
84pub(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 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 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}