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