1use std::collections::BTreeSet;
2
3use bitwarden_error::bitwarden_error;
4use rand::{distributions::Distribution, seq::SliceRandom, RngCore};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8#[cfg(feature = "wasm")]
9use tsify_next::Tsify;
10
11#[allow(missing_docs)]
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum PasswordError {
15 #[error("No character set enabled")]
16 NoCharacterSetEnabled,
17 #[error("Invalid password length")]
18 InvalidLength,
19}
20
21#[derive(Serialize, Deserialize, Debug, JsonSchema)]
23#[serde(rename_all = "camelCase", deny_unknown_fields)]
24#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
25#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
26pub struct PasswordGeneratorRequest {
27 pub lowercase: bool,
29 pub uppercase: bool,
31 pub numbers: bool,
33 pub special: bool,
35
36 pub length: u8,
39
40 pub avoid_ambiguous: bool, pub min_lowercase: Option<u8>,
47 pub min_uppercase: Option<u8>,
50 pub min_number: Option<u8>,
53 pub min_special: Option<u8>,
56}
57
58const DEFAULT_PASSWORD_LENGTH: u8 = 16;
59
60impl Default for PasswordGeneratorRequest {
61 fn default() -> Self {
62 Self {
63 lowercase: true,
64 uppercase: true,
65 numbers: true,
66 special: false,
67 length: DEFAULT_PASSWORD_LENGTH,
68 avoid_ambiguous: false,
69 min_lowercase: None,
70 min_uppercase: None,
71 min_number: None,
72 min_special: None,
73 }
74 }
75}
76
77const UPPER_CHARS_AMBIGUOUS: &[char] = &['I', 'O'];
78const LOWER_CHARS_AMBIGUOUS: &[char] = &['l'];
79const NUMBER_CHARS_AMBIGUOUS: &[char] = &['0', '1'];
80const SPECIAL_CHARS: &[char] = &['!', '@', '#', '$', '%', '^', '&', '*'];
81
82#[derive(Clone, Default)]
87struct CharSet(BTreeSet<char>);
88impl CharSet {
89 pub fn include(self, other: impl IntoIterator<Item = char>) -> Self {
91 self.include_if(true, other)
92 }
93
94 pub fn include_if(mut self, predicate: bool, other: impl IntoIterator<Item = char>) -> Self {
97 if predicate {
98 self.0.extend(other);
99 }
100 self
101 }
102
103 pub fn exclude_if<'a>(
105 self,
106 predicate: bool,
107 other: impl IntoIterator<Item = &'a char>,
108 ) -> Self {
109 if predicate {
110 let other: BTreeSet<_> = other.into_iter().copied().collect();
111 Self(self.0.difference(&other).copied().collect())
112 } else {
113 self
114 }
115 }
116}
117impl<'a> IntoIterator for &'a CharSet {
118 type Item = char;
119 type IntoIter = std::iter::Copied<std::collections::btree_set::Iter<'a, char>>;
120 fn into_iter(self) -> Self::IntoIter {
121 self.0.iter().copied()
122 }
123}
124impl Distribution<char> for CharSet {
125 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> char {
126 let idx = rng.gen_range(0..self.0.len());
127 *self.0.iter().nth(idx).expect("Valid index")
128 }
129}
130
131struct PasswordGeneratorOptions {
135 pub(super) lower: (CharSet, usize),
136 pub(super) upper: (CharSet, usize),
137 pub(super) number: (CharSet, usize),
138 pub(super) special: (CharSet, usize),
139 pub(super) all: (CharSet, usize),
140
141 pub(super) length: usize,
142}
143
144impl PasswordGeneratorRequest {
145 fn validate_options(self) -> Result<PasswordGeneratorOptions, PasswordError> {
148 if !self.lowercase && !self.uppercase && !self.numbers && !self.special {
152 return Err(PasswordError::NoCharacterSetEnabled);
153 }
154
155 if self.length < 4 {
156 return Err(PasswordError::InvalidLength);
157 }
158
159 fn get_minimum(min: Option<u8>, enabled: bool) -> usize {
162 if enabled {
163 usize::max(min.unwrap_or(1) as usize, 1)
164 } else {
165 0
166 }
167 }
168
169 let length = self.length as usize;
170 let min_lowercase = get_minimum(self.min_lowercase, self.lowercase);
171 let min_uppercase = get_minimum(self.min_uppercase, self.uppercase);
172 let min_number = get_minimum(self.min_number, self.numbers);
173 let min_special = get_minimum(self.min_special, self.special);
174
175 let minimum_length = min_lowercase + min_uppercase + min_number + min_special;
177 if minimum_length > length {
178 return Err(PasswordError::InvalidLength);
179 }
180
181 let lower = (
182 CharSet::default()
183 .include_if(self.lowercase, 'a'..='z')
184 .exclude_if(self.avoid_ambiguous, LOWER_CHARS_AMBIGUOUS),
185 min_lowercase,
186 );
187
188 let upper = (
189 CharSet::default()
190 .include_if(self.uppercase, 'A'..='Z')
191 .exclude_if(self.avoid_ambiguous, UPPER_CHARS_AMBIGUOUS),
192 min_uppercase,
193 );
194
195 let number = (
196 CharSet::default()
197 .include_if(self.numbers, '0'..='9')
198 .exclude_if(self.avoid_ambiguous, NUMBER_CHARS_AMBIGUOUS),
199 min_number,
200 );
201
202 let special = (
203 CharSet::default().include_if(self.special, SPECIAL_CHARS.iter().copied()),
204 min_special,
205 );
206
207 let all = (
208 CharSet::default()
209 .include(&lower.0)
210 .include(&upper.0)
211 .include(&number.0)
212 .include(&special.0),
213 length - minimum_length,
214 );
215
216 Ok(PasswordGeneratorOptions {
217 lower,
218 upper,
219 number,
220 special,
221 all,
222 length,
223 })
224 }
225}
226
227pub(crate) fn password(input: PasswordGeneratorRequest) -> Result<String, PasswordError> {
229 let options = input.validate_options()?;
230 Ok(password_with_rng(rand::thread_rng(), options))
231}
232
233fn password_with_rng(mut rng: impl RngCore, options: PasswordGeneratorOptions) -> String {
234 let mut buf: Vec<char> = Vec::with_capacity(options.length);
235
236 let opts = [
237 &options.all,
238 &options.upper,
239 &options.lower,
240 &options.number,
241 &options.special,
242 ];
243 for (set, qty) in opts {
244 buf.extend(set.sample_iter(&mut rng).take(*qty));
245 }
246
247 buf.shuffle(&mut rng);
248
249 buf.iter().collect()
250}
251
252#[cfg(test)]
253mod test {
254 use std::collections::BTreeSet;
255
256 use rand::SeedableRng;
257
258 use super::*;
259
260 fn ref_to_set<'a>(chars: impl IntoIterator<Item = &'a char>) -> BTreeSet<char> {
262 chars.into_iter().copied().collect()
263 }
264 fn to_set(chars: impl IntoIterator<Item = char>) -> BTreeSet<char> {
265 chars.into_iter().collect()
266 }
267
268 #[test]
269 fn test_password_gen_all_charsets_enabled() {
270 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
271
272 let options = PasswordGeneratorRequest {
273 lowercase: true,
274 uppercase: true,
275 numbers: true,
276 special: true,
277 avoid_ambiguous: false,
278 ..Default::default()
279 }
280 .validate_options()
281 .unwrap();
282
283 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
284 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
285 assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
286 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
287
288 let pass = password_with_rng(&mut rng, options);
289 assert_eq!(pass, "Z!^B5r%hUa23dFM@");
290 }
291
292 #[test]
293 fn test_password_gen_only_letters_enabled() {
294 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
295
296 let options = PasswordGeneratorRequest {
297 lowercase: true,
298 uppercase: true,
299 numbers: false,
300 special: false,
301 avoid_ambiguous: false,
302 ..Default::default()
303 }
304 .validate_options()
305 .unwrap();
306
307 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
308 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
309 assert_eq!(to_set(&options.number.0), to_set([]));
310 assert_eq!(to_set(&options.special.0), to_set([]));
311
312 let pass = password_with_rng(&mut rng, options);
313 assert_eq!(pass, "NQiFrGufQMiNUAmj");
314 }
315
316 #[test]
317 fn test_password_gen_only_numbers_and_lower_enabled_no_ambiguous() {
318 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
319
320 let options = PasswordGeneratorRequest {
321 lowercase: true,
322 uppercase: false,
323 numbers: true,
324 special: false,
325 avoid_ambiguous: true,
326 ..Default::default()
327 }
328 .validate_options()
329 .unwrap();
330
331 assert!(to_set(&options.lower.0).is_subset(&to_set('a'..='z')));
332 assert!(to_set(&options.lower.0).is_disjoint(&ref_to_set(LOWER_CHARS_AMBIGUOUS)));
333
334 assert!(to_set(&options.number.0).is_subset(&to_set('0'..='9')));
335 assert!(to_set(&options.number.0).is_disjoint(&ref_to_set(NUMBER_CHARS_AMBIGUOUS)));
336
337 assert_eq!(to_set(&options.upper.0), to_set([]));
338 assert_eq!(to_set(&options.special.0), to_set([]));
339
340 let pass = password_with_rng(&mut rng, options);
341 assert_eq!(pass, "mnjabfz5ct272prf");
342 }
343
344 #[test]
345 fn test_password_gen_only_upper_and_special_enabled_no_ambiguous() {
346 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
347
348 let options = PasswordGeneratorRequest {
349 lowercase: false,
350 uppercase: true,
351 numbers: false,
352 special: true,
353 avoid_ambiguous: true,
354 ..Default::default()
355 }
356 .validate_options()
357 .unwrap();
358
359 assert!(to_set(&options.upper.0).is_subset(&to_set('A'..='Z')));
360 assert!(to_set(&options.upper.0).is_disjoint(&ref_to_set(UPPER_CHARS_AMBIGUOUS)));
361
362 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
363
364 assert_eq!(to_set(&options.lower.0), to_set([]));
365 assert_eq!(to_set(&options.number.0), to_set([]));
366
367 let pass = password_with_rng(&mut rng, options);
368 assert_eq!(pass, "B*GBQANS%UZPQD!K");
369 }
370
371 #[test]
372 fn test_password_gen_minimum_limits() {
373 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
374
375 let options = PasswordGeneratorRequest {
376 lowercase: true,
377 uppercase: true,
378 numbers: true,
379 special: true,
380 avoid_ambiguous: false,
381 length: 24,
382 min_lowercase: Some(5),
383 min_uppercase: Some(5),
384 min_number: Some(5),
385 min_special: Some(5),
386 }
387 .validate_options()
388 .unwrap();
389
390 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
391 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
392 assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
393 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
394
395 assert_eq!(options.lower.1, 5);
396 assert_eq!(options.upper.1, 5);
397 assert_eq!(options.number.1, 5);
398 assert_eq!(options.special.1, 5);
399
400 let pass = password_with_rng(&mut rng, options);
401 assert_eq!(pass, "236q5!a#R%PG5rI%k1!*@uRt");
402 }
403}