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#[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 pub num_words: u8,
29 pub word_separator: String,
31 pub capitalize: bool,
33 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
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(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 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 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}