1use std::collections::BTreeSet;
2
3use bitwarden_error::bitwarden_error;
4use rand::{Rng, RngExt, distr::Distribution, seq::SliceRandom};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[allow(missing_docs)]
10#[bitwarden_error(flat)]
11#[derive(Debug, Error)]
12pub enum PasswordError {
13 #[error("No character set enabled")]
14 NoCharacterSetEnabled,
15 #[error("Invalid password length")]
16 InvalidLength,
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#[bitwarden_ffi::wasm_record]
24pub struct PasswordGeneratorRequest {
25 pub lowercase: bool,
27 pub uppercase: bool,
29 pub numbers: bool,
31 pub special: bool,
33
34 pub length: u8,
37
38 pub avoid_ambiguous: bool, pub min_lowercase: Option<u8>,
45 pub min_uppercase: Option<u8>,
48 pub min_number: Option<u8>,
51 pub min_special: Option<u8>,
54
55 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
63 #[cfg_attr(feature = "wasm", tsify(optional))]
64 pub custom_required_chars: Option<String>,
65 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
72 #[cfg_attr(feature = "wasm", tsify(optional))]
73 pub custom_allowed_chars: Option<String>,
74
75 #[cfg_attr(feature = "uniffi", uniffi(default = None))]
80 #[cfg_attr(feature = "wasm", tsify(optional))]
81 pub max_consecutive: Option<u8>,
82}
83
84const DEFAULT_PASSWORD_LENGTH: u8 = 16;
85
86pub const MINIMUM_PASSWORD_LENGTH: u8 = 5;
90pub const MAXIMUM_PASSWORD_LENGTH: u8 = 128;
92pub const MINIMUM_MIN_CHAR_COUNT: u8 = 0;
94pub const MAXIMUM_MIN_CHAR_COUNT: u8 = 9;
96
97impl Default for PasswordGeneratorRequest {
98 fn default() -> Self {
99 Self {
100 lowercase: true,
101 uppercase: true,
102 numbers: true,
103 special: false,
104 length: DEFAULT_PASSWORD_LENGTH,
105 avoid_ambiguous: false,
106 min_lowercase: None,
107 min_uppercase: None,
108 min_number: None,
109 min_special: None,
110 custom_required_chars: None,
111 custom_allowed_chars: None,
112 max_consecutive: None,
113 }
114 }
115}
116
117fn sanitize_custom_chars(s: &str) -> Vec<char> {
120 let mut seen = BTreeSet::new();
121 s.chars()
122 .filter(|c| c.is_ascii_graphic())
123 .filter(|c| seen.insert(*c))
124 .collect()
125}
126
127const UPPER_CHARS_AMBIGUOUS: &[char] = &['I', 'O'];
128const LOWER_CHARS_AMBIGUOUS: &[char] = &['l'];
129const NUMBER_CHARS_AMBIGUOUS: &[char] = &['0', '1'];
130const SPECIAL_CHARS: &[char] = &['!', '@', '#', '$', '%', '^', '&', '*'];
131
132#[derive(Clone, Default)]
137struct CharSet(BTreeSet<char>);
138impl CharSet {
139 pub fn include(self, other: impl IntoIterator<Item = char>) -> Self {
141 self.include_if(true, other)
142 }
143
144 pub fn include_if(mut self, predicate: bool, other: impl IntoIterator<Item = char>) -> Self {
147 if predicate {
148 self.0.extend(other);
149 }
150 self
151 }
152
153 pub fn exclude_if<'a>(
155 self,
156 predicate: bool,
157 other: impl IntoIterator<Item = &'a char>,
158 ) -> Self {
159 if predicate {
160 let other: BTreeSet<_> = other.into_iter().copied().collect();
161 Self(self.0.difference(&other).copied().collect())
162 } else {
163 self
164 }
165 }
166}
167impl<'a> IntoIterator for &'a CharSet {
168 type Item = char;
169 type IntoIter = std::iter::Copied<std::collections::btree_set::Iter<'a, char>>;
170 fn into_iter(self) -> Self::IntoIter {
171 self.0.iter().copied()
172 }
173}
174impl Distribution<char> for CharSet {
175 fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> char {
176 let idx = rng.random_range(0..self.0.len());
177 *self.0.iter().nth(idx).expect("Valid index")
178 }
179}
180
181struct PasswordGeneratorOptions {
185 lower: (CharSet, usize),
186 upper: (CharSet, usize),
187 number: (CharSet, usize),
188 special: (CharSet, usize),
189 custom: (CharSet, usize),
192 all: (CharSet, usize),
193
194 length: usize,
195
196 max_consecutive: Option<usize>,
200}
201
202impl PasswordGeneratorRequest {
203 fn validate_options(self) -> Result<PasswordGeneratorOptions, PasswordError> {
206 let custom_required = self
211 .custom_required_chars
212 .as_deref()
213 .map(sanitize_custom_chars)
214 .unwrap_or_default();
215 let custom_allowed: Vec<char> = self
216 .custom_allowed_chars
217 .as_deref()
218 .map(sanitize_custom_chars)
219 .unwrap_or_default();
220
221 if !self.lowercase
223 && !self.uppercase
224 && !self.numbers
225 && !self.special
226 && custom_required.is_empty()
227 && custom_allowed.is_empty()
228 {
229 return Err(PasswordError::NoCharacterSetEnabled);
230 }
231
232 if self.length < 4 {
233 return Err(PasswordError::InvalidLength);
234 }
235
236 fn get_minimum(min: Option<u8>, enabled: bool) -> usize {
239 if enabled {
240 usize::max(min.unwrap_or(1) as usize, 1)
241 } else {
242 0
243 }
244 }
245
246 let length = self.length as usize;
247 let min_lowercase = get_minimum(self.min_lowercase, self.lowercase);
248 let min_uppercase = get_minimum(self.min_uppercase, self.uppercase);
249 let min_number = get_minimum(self.min_number, self.numbers);
250 let min_special = get_minimum(self.min_special, self.special);
251 let min_custom = if custom_required.is_empty() { 0 } else { 1 };
252
253 let minimum_length = min_lowercase + min_uppercase + min_number + min_special + min_custom;
255 if minimum_length > length {
256 return Err(PasswordError::InvalidLength);
257 }
258
259 let lower = (
260 CharSet::default()
261 .include_if(self.lowercase, 'a'..='z')
262 .exclude_if(self.avoid_ambiguous, LOWER_CHARS_AMBIGUOUS),
263 min_lowercase,
264 );
265
266 let upper = (
267 CharSet::default()
268 .include_if(self.uppercase, 'A'..='Z')
269 .exclude_if(self.avoid_ambiguous, UPPER_CHARS_AMBIGUOUS),
270 min_uppercase,
271 );
272
273 let number = (
274 CharSet::default()
275 .include_if(self.numbers, '0'..='9')
276 .exclude_if(self.avoid_ambiguous, NUMBER_CHARS_AMBIGUOUS),
277 min_number,
278 );
279
280 let special = (
281 CharSet::default().include_if(self.special, SPECIAL_CHARS.iter().copied()),
282 min_special,
283 );
284
285 let custom = (
286 CharSet::default().include(custom_required.iter().copied()),
287 min_custom,
288 );
289
290 let all = (
291 CharSet::default()
292 .include(&lower.0)
293 .include(&upper.0)
294 .include(&number.0)
295 .include(&special.0)
296 .include(&custom.0)
297 .include(custom_allowed.iter().copied()),
298 length - minimum_length,
299 );
300
301 let max_consecutive = match self.max_consecutive {
305 None => None,
306 Some(0) => return Err(PasswordError::InvalidLength),
307 Some(n) => Some(n as usize),
308 };
309
310 Ok(PasswordGeneratorOptions {
311 lower,
312 upper,
313 number,
314 special,
315 custom,
316 all,
317 length,
318 max_consecutive,
319 })
320 }
321}
322
323pub(crate) fn password(input: PasswordGeneratorRequest) -> Result<String, PasswordError> {
325 let options = input.validate_options()?;
326 Ok(password_with_rng(bitwarden_random::rng(), options))
327}
328
329#[cfg(test)]
332pub(crate) fn password_with_rng_for_test(
333 rng: impl Rng,
334 input: PasswordGeneratorRequest,
335) -> Result<String, PasswordError> {
336 let options = input.validate_options()?;
337 Ok(password_with_rng(rng, options))
338}
339
340fn password_with_rng(mut rng: impl Rng, options: PasswordGeneratorOptions) -> String {
341 let mut buf: Vec<char> = Vec::with_capacity(options.length);
342
343 let opts = [
344 &options.all,
345 &options.upper,
346 &options.lower,
347 &options.number,
348 &options.special,
349 &options.custom,
350 ];
351 for (set, qty) in opts {
352 buf.extend(set.sample_iter(&mut rng).take(*qty));
353 }
354
355 buf.shuffle(&mut rng);
356
357 if let Some(limit) = options.max_consecutive {
358 const MAX_RESHUFFLES: u8 = 16;
362 let mut tries = 0;
363 while violates_max_consecutive(&buf, limit) && tries < MAX_RESHUFFLES {
364 buf.shuffle(&mut rng);
365 tries += 1;
366 }
367 if violates_max_consecutive(&buf, limit) {
368 repair_consecutive(&mut buf, limit);
369 }
370 }
371
372 buf.iter().collect()
373}
374
375fn violates_max_consecutive(buf: &[char], limit: usize) -> bool {
377 if limit == 0 || buf.len() <= limit {
378 return false;
379 }
380 let mut run = 1usize;
381 for w in buf.windows(2) {
382 if w[0] == w[1] {
383 run += 1;
384 if run > limit {
385 return true;
386 }
387 } else {
388 run = 1;
389 }
390 }
391 false
392}
393
394fn repair_consecutive(buf: &mut [char], limit: usize) {
399 if limit == 0 || buf.len() <= limit {
400 return;
401 }
402 let mut run = 1usize;
403 let mut i = 1;
404 while i < buf.len() {
405 if buf[i] == buf[i - 1] {
406 run += 1;
407 if run > limit {
408 let target = (i + 1..buf.len())
411 .find(|&k| buf[k] != buf[i] && (k + 1 == buf.len() || buf[k + 1] != buf[i]));
412 match target {
413 Some(j) => {
414 buf.swap(i, j);
415 run = 1;
416 }
417 None => {
418 return;
422 }
423 }
424 }
425 } else {
426 run = 1;
427 }
428 i += 1;
429 }
430}
431
432#[cfg(test)]
433mod test {
434 use std::collections::BTreeSet;
435
436 use rand::SeedableRng;
437
438 use super::*;
439
440 fn ref_to_set<'a>(chars: impl IntoIterator<Item = &'a char>) -> BTreeSet<char> {
442 chars.into_iter().copied().collect()
443 }
444 fn to_set(chars: impl IntoIterator<Item = char>) -> BTreeSet<char> {
445 chars.into_iter().collect()
446 }
447
448 #[test]
449 fn test_password_gen_all_charsets_enabled() {
450 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
451
452 let options = PasswordGeneratorRequest {
453 lowercase: true,
454 uppercase: true,
455 numbers: true,
456 special: true,
457 avoid_ambiguous: false,
458 ..Default::default()
459 }
460 .validate_options()
461 .unwrap();
462
463 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
464 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
465 assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
466 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
467
468 let pass = password_with_rng(&mut rng, options);
469 assert_eq!(pass, "0oA772tQjaUO$a@L");
470 }
471
472 #[test]
473 fn test_password_gen_only_letters_enabled() {
474 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
475
476 let options = PasswordGeneratorRequest {
477 lowercase: true,
478 uppercase: true,
479 numbers: false,
480 special: false,
481 avoid_ambiguous: false,
482 ..Default::default()
483 }
484 .validate_options()
485 .unwrap();
486
487 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
488 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
489 assert_eq!(to_set(&options.number.0), to_set([]));
490 assert_eq!(to_set(&options.special.0), to_set([]));
491
492 let pass = password_with_rng(&mut rng, options);
493 assert_eq!(pass, "FrNSJGvhnAbXggMU");
494 }
495
496 #[test]
497 fn test_password_gen_only_numbers_and_lower_enabled_no_ambiguous() {
498 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
499
500 let options = PasswordGeneratorRequest {
501 lowercase: true,
502 uppercase: false,
503 numbers: true,
504 special: false,
505 avoid_ambiguous: true,
506 ..Default::default()
507 }
508 .validate_options()
509 .unwrap();
510
511 assert!(to_set(&options.lower.0).is_subset(&to_set('a'..='z')));
512 assert!(to_set(&options.lower.0).is_disjoint(&ref_to_set(LOWER_CHARS_AMBIGUOUS)));
513
514 assert!(to_set(&options.number.0).is_subset(&to_set('0'..='9')));
515 assert!(to_set(&options.number.0).is_disjoint(&ref_to_set(NUMBER_CHARS_AMBIGUOUS)));
516
517 assert_eq!(to_set(&options.upper.0), to_set([]));
518 assert_eq!(to_set(&options.special.0), to_set([]));
519
520 let pass = password_with_rng(&mut rng, options);
521 assert_eq!(pass, "5uat85wos2jg4n9f");
522 }
523
524 #[test]
525 fn test_password_gen_only_upper_and_special_enabled_no_ambiguous() {
526 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
527
528 let options = PasswordGeneratorRequest {
529 lowercase: false,
530 uppercase: true,
531 numbers: false,
532 special: true,
533 avoid_ambiguous: true,
534 ..Default::default()
535 }
536 .validate_options()
537 .unwrap();
538
539 assert!(to_set(&options.upper.0).is_subset(&to_set('A'..='Z')));
540 assert!(to_set(&options.upper.0).is_disjoint(&ref_to_set(UPPER_CHARS_AMBIGUOUS)));
541
542 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
543
544 assert_eq!(to_set(&options.lower.0), to_set([]));
545 assert_eq!(to_set(&options.number.0), to_set([]));
546
547 let pass = password_with_rng(&mut rng, options);
548 assert_eq!(pass, "%VBT*%YPT!LH$PAF");
549 }
550
551 #[test]
552 fn test_password_gen_minimum_limits() {
553 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
554
555 let options = PasswordGeneratorRequest {
556 lowercase: true,
557 uppercase: true,
558 numbers: true,
559 special: true,
560 avoid_ambiguous: false,
561 length: 24,
562 min_lowercase: Some(5),
563 min_uppercase: Some(5),
564 min_number: Some(5),
565 min_special: Some(5),
566 ..Default::default()
567 }
568 .validate_options()
569 .unwrap();
570
571 assert_eq!(to_set(&options.lower.0), to_set('a'..='z'));
572 assert_eq!(to_set(&options.upper.0), to_set('A'..='Z'));
573 assert_eq!(to_set(&options.number.0), to_set('0'..='9'));
574 assert_eq!(to_set(&options.special.0), ref_to_set(SPECIAL_CHARS));
575
576 assert_eq!(options.lower.1, 5);
577 assert_eq!(options.upper.1, 5);
578 assert_eq!(options.number.1, 5);
579 assert_eq!(options.special.1, 5);
580
581 let pass = password_with_rng(&mut rng, options);
582 assert_eq!(pass, "t&c0L73*D*G%aak7goq!N2T4");
583 }
584
585 fn longest_run(s: &str) -> usize {
586 let mut longest = 0usize;
587 let mut run = 0usize;
588 let mut prev: Option<char> = None;
589 for c in s.chars() {
590 if prev == Some(c) {
591 run += 1;
592 } else {
593 run = 1;
594 prev = Some(c);
595 }
596 longest = longest.max(run);
597 }
598 longest
599 }
600
601 #[test]
602 fn test_password_gen_honors_max_consecutive() {
603 for seed_byte in 0u8..64 {
607 let mut rng = rand_chacha::ChaCha8Rng::from_seed([seed_byte; 32]);
608 let options = PasswordGeneratorRequest {
609 lowercase: true,
610 uppercase: true,
611 numbers: true,
612 special: true,
613 avoid_ambiguous: false,
614 length: 64,
615 max_consecutive: Some(2),
616 ..Default::default()
617 }
618 .validate_options()
619 .unwrap();
620 let pass = password_with_rng(&mut rng, options);
621 let run = longest_run(&pass);
622 assert!(
623 run <= 2,
624 "seed={seed_byte}: produced {pass:?} with run of length {run} (>2)"
625 );
626 }
627 }
628
629 #[test]
630 fn test_password_gen_max_consecutive_one_breaks_pairs() {
631 let mut rng = rand_chacha::ChaCha8Rng::from_seed([7u8; 32]);
633 let options = PasswordGeneratorRequest {
634 lowercase: true,
635 uppercase: true,
636 numbers: true,
637 special: true,
638 avoid_ambiguous: false,
639 length: 32,
640 max_consecutive: Some(1),
641 ..Default::default()
642 }
643 .validate_options()
644 .unwrap();
645 let pass = password_with_rng(&mut rng, options);
646 assert!(
647 longest_run(&pass) <= 1,
648 "produced {pass:?} with adjacent duplicate"
649 );
650 }
651
652 #[test]
653 fn test_password_gen_max_consecutive_zero_is_rejected() {
654 let result = PasswordGeneratorRequest {
655 lowercase: true,
656 length: 14,
657 max_consecutive: Some(0),
658 ..Default::default()
659 }
660 .validate_options();
661 assert!(
662 matches!(result, Err(PasswordError::InvalidLength)),
663 "expected InvalidLength for max_consecutive=Some(0)"
664 );
665 }
666
667 #[test]
668 fn test_password_gen_max_consecutive_none_is_unconstrained() {
669 let mut rng_a = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
671 let mut rng_b = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
672 let opts_a = PasswordGeneratorRequest {
673 lowercase: true,
674 uppercase: true,
675 numbers: true,
676 special: true,
677 avoid_ambiguous: false,
678 ..Default::default()
679 }
680 .validate_options()
681 .unwrap();
682 let opts_b = PasswordGeneratorRequest {
683 lowercase: true,
684 uppercase: true,
685 numbers: true,
686 special: true,
687 avoid_ambiguous: false,
688 max_consecutive: None,
689 ..Default::default()
690 }
691 .validate_options()
692 .unwrap();
693 assert_eq!(
694 password_with_rng(&mut rng_a, opts_a),
695 password_with_rng(&mut rng_b, opts_b)
696 );
697 }
698
699 #[test]
700 fn test_custom_required_chars_appear_in_output() {
701 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
704 let options = PasswordGeneratorRequest {
705 lowercase: true,
706 uppercase: true,
707 numbers: true,
708 special: true,
709 avoid_ambiguous: false,
710 length: 32,
711 custom_required_chars: Some("!@#".to_string()),
712 ..Default::default()
713 }
714 .validate_options()
715 .unwrap();
716
717 assert_eq!(options.custom.1, 1);
719 assert_eq!(to_set(&options.custom.0), BTreeSet::from(['!', '@', '#']));
720
721 let pass = password_with_rng(&mut rng, options);
722 let any = pass.chars().any(|c| matches!(c, '!' | '@' | '#'));
723 assert!(any, "expected at least one of !@# in {pass:?}");
724 }
725
726 #[test]
727 fn test_custom_allowed_chars_extend_pool_without_requirement() {
728 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
732 let options = PasswordGeneratorRequest {
733 lowercase: false,
734 uppercase: false,
735 numbers: false,
736 special: false,
737 avoid_ambiguous: false,
738 length: 16,
739 custom_allowed_chars: Some("XYZ".to_string()),
740 ..Default::default()
741 }
742 .validate_options()
743 .unwrap();
744
745 assert!(to_set(&options.custom.0).is_empty());
747 assert_eq!(options.custom.1, 0);
748 assert_eq!(to_set(&options.all.0), BTreeSet::from(['X', 'Y', 'Z']));
750
751 let pass = password_with_rng(&mut rng, options);
752 assert!(
753 pass.chars().all(|c| matches!(c, 'X' | 'Y' | 'Z')),
754 "expected output to consist only of XYZ, got {pass:?}"
755 );
756 assert_eq!(pass.chars().count(), 16);
757 }
758
759 #[test]
760 fn test_custom_required_is_sole_charset_when_no_standards_enabled() {
761 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
764 let options = PasswordGeneratorRequest {
765 lowercase: false,
766 uppercase: false,
767 numbers: false,
768 special: false,
769 avoid_ambiguous: false,
770 length: 8,
771 custom_required_chars: Some("abc".to_string()),
772 ..Default::default()
773 }
774 .validate_options()
775 .unwrap();
776
777 assert_eq!(options.custom.1, 1);
778 let pass = password_with_rng(&mut rng, options);
779 assert!(
780 pass.chars().all(|c| matches!(c, 'a' | 'b' | 'c')),
781 "expected output to consist only of abc, got {pass:?}"
782 );
783 assert_eq!(pass.chars().count(), 8);
784 }
785
786 #[test]
787 fn test_custom_chars_are_sanitized() {
788 let options = PasswordGeneratorRequest {
790 lowercase: false,
791 uppercase: false,
792 numbers: false,
793 special: false,
794 avoid_ambiguous: false,
795 length: 8,
796 custom_required_chars: Some(" a\tbb!@\n".to_string()),
797 ..Default::default()
798 }
799 .validate_options()
800 .unwrap();
801
802 assert_eq!(
804 to_set(&options.custom.0),
805 BTreeSet::from(['a', 'b', '!', '@'])
806 );
807 }
808
809 #[test]
810 fn test_custom_required_and_allowed_compose() {
811 let mut rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]);
814 let options = PasswordGeneratorRequest {
815 lowercase: false,
816 uppercase: false,
817 numbers: false,
818 special: false,
819 avoid_ambiguous: false,
820 length: 16,
821 custom_required_chars: Some("!".to_string()),
822 custom_allowed_chars: Some("abc".to_string()),
823 ..Default::default()
824 }
825 .validate_options()
826 .unwrap();
827
828 assert_eq!(to_set(&options.custom.0), BTreeSet::from(['!']));
829 assert_eq!(options.custom.1, 1);
830 assert_eq!(to_set(&options.all.0), BTreeSet::from(['!', 'a', 'b', 'c']));
831
832 let pass = password_with_rng(&mut rng, options);
833 assert!(
834 pass.contains('!'),
835 "expected required ! to appear in {pass:?}"
836 );
837 assert!(
838 pass.chars().all(|c| matches!(c, '!' | 'a' | 'b' | 'c')),
839 "expected output to consist only of !abc, got {pass:?}"
840 );
841 }
842}