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#[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
49pub const MINIMUM_PASSPHRASE_NUM_WORDS: u8 = 3;
51pub const MAXIMUM_PASSPHRASE_NUM_WORDS: u8 = 20;
53
54struct 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 fn validate_options(self) -> Result<ValidPassphraseGeneratorOptions, PassphraseError> {
68 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
87pub(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 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 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 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}