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