mas_handlers/
passwords.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use std::{collections::HashMap, sync::Arc};
8
9use anyhow::Context;
10use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
11use futures_util::future::OptionFuture;
12use pbkdf2::Pbkdf2;
13use rand::{CryptoRng, RngCore, SeedableRng, distributions::Standard, prelude::Distribution};
14use thiserror::Error;
15use zeroize::Zeroizing;
16use zxcvbn::zxcvbn;
17
18pub type SchemeVersion = u16;
19
20#[derive(Debug, Error)]
21#[error("Password manager is disabled")]
22pub struct PasswordManagerDisabledError;
23
24#[derive(Clone)]
25pub struct PasswordManager {
26    inner: Option<Arc<InnerPasswordManager>>,
27}
28
29struct InnerPasswordManager {
30    /// Minimum complexity score of new passwords (between 0 and 4) as evaluated
31    /// by zxcvbn.
32    minimum_complexity: u8,
33    current_hasher: Hasher,
34    current_version: SchemeVersion,
35
36    /// A map of "old" hashers used only for verification
37    other_hashers: HashMap<SchemeVersion, Hasher>,
38}
39
40impl PasswordManager {
41    /// Creates a new [`PasswordManager`] from an iterator and a minimum allowed
42    /// complexity score between 0 and 4. The first item in
43    /// the iterator will be the default hashing scheme.
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if the iterator was empty
48    pub fn new<I: IntoIterator<Item = (SchemeVersion, Hasher)>>(
49        minimum_complexity: u8,
50        iter: I,
51    ) -> Result<Self, anyhow::Error> {
52        let mut iter = iter.into_iter();
53
54        // Take the first hasher as the current hasher
55        let (current_version, current_hasher) = iter
56            .next()
57            .context("Iterator must have at least one item")?;
58
59        // Collect the other hashers in a map used only in verification
60        let other_hashers = iter.collect();
61
62        Ok(Self {
63            inner: Some(Arc::new(InnerPasswordManager {
64                minimum_complexity,
65                current_hasher,
66                current_version,
67                other_hashers,
68            })),
69        })
70    }
71
72    /// Creates a new disabled password manager
73    #[must_use]
74    pub const fn disabled() -> Self {
75        Self { inner: None }
76    }
77
78    /// Checks if the password manager is enabled or not
79    #[must_use]
80    pub const fn is_enabled(&self) -> bool {
81        self.inner.is_some()
82    }
83
84    /// Get the inner password manager
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the password manager is disabled
89    fn get_inner(&self) -> Result<Arc<InnerPasswordManager>, PasswordManagerDisabledError> {
90        self.inner.clone().ok_or(PasswordManagerDisabledError)
91    }
92
93    /// Returns true if and only if the given password satisfies the minimum
94    /// complexity requirements.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the password manager is disabled
99    pub fn is_password_complex_enough(&self, password: &str) -> Result<bool, anyhow::Error> {
100        let inner = self.get_inner()?;
101        let score = zxcvbn(password, &[]);
102        Ok(u8::from(score.score()) >= inner.minimum_complexity)
103    }
104
105    /// Hash a password with the default hashing scheme.
106    /// Returns the version of the hashing scheme used and the hashed password.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error if the hashing failed or if the password manager is
111    /// disabled
112    #[tracing::instrument(name = "passwords.hash", skip_all)]
113    pub async fn hash<R: CryptoRng + RngCore + Send>(
114        &self,
115        rng: R,
116        password: Zeroizing<Vec<u8>>,
117    ) -> Result<(SchemeVersion, String), anyhow::Error> {
118        let inner = self.get_inner()?;
119
120        // Seed a future-local RNG so the RNG passed in parameters doesn't have to be
121        // 'static
122        let rng = rand_chacha::ChaChaRng::from_rng(rng)?;
123        let span = tracing::Span::current();
124
125        // `inner` is being moved in the blocking task, so we need to copy the version
126        // first
127        let version = inner.current_version;
128
129        let hashed = tokio::task::spawn_blocking(move || {
130            span.in_scope(move || inner.current_hasher.hash_blocking(rng, &password))
131        })
132        .await??;
133
134        Ok((version, hashed))
135    }
136
137    /// Verify a password hash for the given hashing scheme.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the password hash verification failed or if the
142    /// password manager is disabled
143    #[tracing::instrument(name = "passwords.verify", skip_all, fields(%scheme))]
144    pub async fn verify(
145        &self,
146        scheme: SchemeVersion,
147        password: Zeroizing<Vec<u8>>,
148        hashed_password: String,
149    ) -> Result<(), anyhow::Error> {
150        let inner = self.get_inner()?;
151        let span = tracing::Span::current();
152
153        tokio::task::spawn_blocking(move || {
154            span.in_scope(move || {
155                let hasher = if scheme == inner.current_version {
156                    &inner.current_hasher
157                } else {
158                    inner
159                        .other_hashers
160                        .get(&scheme)
161                        .context("Hashing scheme not found")?
162                };
163
164                hasher.verify_blocking(&hashed_password, &password)
165            })
166        })
167        .await??;
168
169        Ok(())
170    }
171
172    /// Verify a password hash for the given hashing scheme, and upgrade it on
173    /// the fly, if it was not hashed with the default scheme
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the password hash verification failed or if the
178    /// password manager is disabled
179    #[tracing::instrument(name = "passwords.verify_and_upgrade", skip_all, fields(%scheme))]
180    pub async fn verify_and_upgrade<R: CryptoRng + RngCore + Send>(
181        &self,
182        rng: R,
183        scheme: SchemeVersion,
184        password: Zeroizing<Vec<u8>>,
185        hashed_password: String,
186    ) -> Result<Option<(SchemeVersion, String)>, anyhow::Error> {
187        let inner = self.get_inner()?;
188
189        // If the current scheme isn't the default one, we also hash with the default
190        // one so that
191        let new_hash_fut: OptionFuture<_> = (scheme != inner.current_version)
192            .then(|| self.hash(rng, password.clone()))
193            .into();
194
195        let verify_fut = self.verify(scheme, password, hashed_password);
196
197        let (new_hash_res, verify_res) = tokio::join!(new_hash_fut, verify_fut);
198        verify_res?;
199
200        let new_hash = new_hash_res.transpose()?;
201
202        Ok(new_hash)
203    }
204}
205
206/// A hashing scheme, with an optional pepper
207pub struct Hasher {
208    algorithm: Algorithm,
209    pepper: Option<Vec<u8>>,
210}
211
212impl Hasher {
213    /// Creates a new hashing scheme based on the bcrypt algorithm
214    #[must_use]
215    pub const fn bcrypt(cost: Option<u32>, pepper: Option<Vec<u8>>) -> Self {
216        let algorithm = Algorithm::Bcrypt { cost };
217        Self { algorithm, pepper }
218    }
219
220    /// Creates a new hashing scheme based on the argon2id algorithm
221    #[must_use]
222    pub const fn argon2id(pepper: Option<Vec<u8>>) -> Self {
223        let algorithm = Algorithm::Argon2id;
224        Self { algorithm, pepper }
225    }
226
227    /// Creates a new hashing scheme based on the pbkdf2 algorithm
228    #[must_use]
229    pub const fn pbkdf2(pepper: Option<Vec<u8>>) -> Self {
230        let algorithm = Algorithm::Pbkdf2;
231        Self { algorithm, pepper }
232    }
233
234    fn hash_blocking<R: CryptoRng + RngCore>(
235        &self,
236        rng: R,
237        password: &[u8],
238    ) -> Result<String, anyhow::Error> {
239        self.algorithm
240            .hash_blocking(rng, password, self.pepper.as_deref())
241    }
242
243    fn verify_blocking(&self, hashed_password: &str, password: &[u8]) -> Result<(), anyhow::Error> {
244        self.algorithm
245            .verify_blocking(hashed_password, password, self.pepper.as_deref())
246    }
247}
248
249#[derive(Debug, Clone, Copy)]
250enum Algorithm {
251    Bcrypt { cost: Option<u32> },
252    Argon2id,
253    Pbkdf2,
254}
255
256impl Algorithm {
257    fn hash_blocking<R: CryptoRng + RngCore>(
258        self,
259        mut rng: R,
260        password: &[u8],
261        pepper: Option<&[u8]>,
262    ) -> Result<String, anyhow::Error> {
263        match self {
264            Self::Bcrypt { cost } => {
265                let mut password = Zeroizing::new(password.to_vec());
266                if let Some(pepper) = pepper {
267                    password.extend_from_slice(pepper);
268                }
269
270                let salt = Standard.sample(&mut rng);
271
272                let hashed = bcrypt::hash_with_salt(password, cost.unwrap_or(12), salt)?;
273                Ok(hashed.format_for_version(bcrypt::Version::TwoB))
274            }
275
276            Self::Argon2id => {
277                let algorithm = argon2::Algorithm::default();
278                let version = argon2::Version::default();
279                let params = argon2::Params::default();
280
281                let phf = if let Some(secret) = pepper {
282                    Argon2::new_with_secret(secret, algorithm, version, params)?
283                } else {
284                    Argon2::new(algorithm, version, params)
285                };
286
287                let salt = SaltString::generate(rng);
288                let hashed = phf.hash_password(password.as_ref(), &salt)?;
289                Ok(hashed.to_string())
290            }
291
292            Self::Pbkdf2 => {
293                let mut password = Zeroizing::new(password.to_vec());
294                if let Some(pepper) = pepper {
295                    password.extend_from_slice(pepper);
296                }
297
298                let salt = SaltString::generate(rng);
299                let hashed = Pbkdf2.hash_password(password.as_ref(), &salt)?;
300                Ok(hashed.to_string())
301            }
302        }
303    }
304
305    fn verify_blocking(
306        self,
307        hashed_password: &str,
308        password: &[u8],
309        pepper: Option<&[u8]>,
310    ) -> Result<(), anyhow::Error> {
311        match self {
312            Algorithm::Bcrypt { .. } => {
313                let mut password = Zeroizing::new(password.to_vec());
314                if let Some(pepper) = pepper {
315                    password.extend_from_slice(pepper);
316                }
317
318                let result = bcrypt::verify(password, hashed_password)?;
319                anyhow::ensure!(result, "wrong password");
320            }
321
322            Algorithm::Argon2id => {
323                let algorithm = argon2::Algorithm::default();
324                let version = argon2::Version::default();
325                let params = argon2::Params::default();
326
327                let phf = if let Some(secret) = pepper {
328                    Argon2::new_with_secret(secret, algorithm, version, params)?
329                } else {
330                    Argon2::new(algorithm, version, params)
331                };
332
333                let hashed_password = PasswordHash::new(hashed_password)?;
334
335                phf.verify_password(password.as_ref(), &hashed_password)?;
336            }
337
338            Algorithm::Pbkdf2 => {
339                let mut password = Zeroizing::new(password.to_vec());
340                if let Some(pepper) = pepper {
341                    password.extend_from_slice(pepper);
342                }
343
344                let hashed_password = PasswordHash::new(hashed_password)?;
345
346                Pbkdf2.verify_password(password.as_ref(), &hashed_password)?;
347            }
348        }
349
350        Ok(())
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use rand::SeedableRng;
357
358    use super::*;
359
360    #[test]
361    fn hashing_bcrypt() {
362        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
363        let password = b"hunter2";
364        let password2 = b"wrong-password";
365        let pepper = b"a-secret-pepper";
366        let pepper2 = b"the-wrong-pepper";
367
368        let alg = Algorithm::Bcrypt { cost: Some(10) };
369        // Hash with a pepper
370        let hash = alg
371            .hash_blocking(&mut rng, password, Some(pepper))
372            .expect("Couldn't hash password");
373        insta::assert_snapshot!(hash);
374
375        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
376        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
377        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
378        assert!(alg.verify_blocking(&hash, password, None).is_err());
379
380        // Hash without pepper
381        let hash = alg
382            .hash_blocking(&mut rng, password, None)
383            .expect("Couldn't hash password");
384        insta::assert_snapshot!(hash);
385
386        assert!(alg.verify_blocking(&hash, password, None).is_ok());
387        assert!(alg.verify_blocking(&hash, password2, None).is_err());
388        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
389    }
390
391    #[test]
392    fn hashing_argon2id() {
393        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
394        let password = b"hunter2";
395        let password2 = b"wrong-password";
396        let pepper = b"a-secret-pepper";
397        let pepper2 = b"the-wrong-pepper";
398
399        let alg = Algorithm::Argon2id;
400        // Hash with a pepper
401        let hash = alg
402            .hash_blocking(&mut rng, password, Some(pepper))
403            .expect("Couldn't hash password");
404        insta::assert_snapshot!(hash);
405
406        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
407        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
408        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
409        assert!(alg.verify_blocking(&hash, password, None).is_err());
410
411        // Hash without pepper
412        let hash = alg
413            .hash_blocking(&mut rng, password, None)
414            .expect("Couldn't hash password");
415        insta::assert_snapshot!(hash);
416
417        assert!(alg.verify_blocking(&hash, password, None).is_ok());
418        assert!(alg.verify_blocking(&hash, password2, None).is_err());
419        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
420    }
421
422    #[test]
423    #[ignore = "this is particularly slow (20s+ seconds)"]
424    fn hashing_pbkdf2() {
425        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
426        let password = b"hunter2";
427        let password2 = b"wrong-password";
428        let pepper = b"a-secret-pepper";
429        let pepper2 = b"the-wrong-pepper";
430
431        let alg = Algorithm::Pbkdf2;
432        // Hash with a pepper
433        let hash = alg
434            .hash_blocking(&mut rng, password, Some(pepper))
435            .expect("Couldn't hash password");
436        insta::assert_snapshot!(hash);
437
438        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
439        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
440        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
441        assert!(alg.verify_blocking(&hash, password, None).is_err());
442
443        // Hash without pepper
444        let hash = alg
445            .hash_blocking(&mut rng, password, None)
446            .expect("Couldn't hash password");
447        insta::assert_snapshot!(hash);
448
449        assert!(alg.verify_blocking(&hash, password, None).is_ok());
450        assert!(alg.verify_blocking(&hash, password2, None).is_err());
451        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
452    }
453
454    #[allow(clippy::too_many_lines)]
455    #[tokio::test]
456    async fn hash_verify_and_upgrade() {
457        // Tests the whole password manager, by hashing a password and upgrading it
458        // after changing the hashing schemes. The salt generation is done with a seeded
459        // RNG, so that we can do stable snapshots of hashed passwords
460        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
461        let password = Zeroizing::new(b"hunter2".to_vec());
462        let wrong_password = Zeroizing::new(b"wrong-password".to_vec());
463
464        let manager = PasswordManager::new(
465            0,
466            [
467                // Start with one hashing scheme: the one used by synapse, bcrypt + pepper
468                (
469                    1,
470                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec())),
471                ),
472            ],
473        )
474        .unwrap();
475
476        let (version, hash) = manager
477            .hash(&mut rng, password.clone())
478            .await
479            .expect("Failed to hash");
480
481        assert_eq!(version, 1);
482        insta::assert_snapshot!(hash);
483
484        // Just verifying works
485        manager
486            .verify(version, password.clone(), hash.clone())
487            .await
488            .expect("Failed to verify");
489
490        // And doesn't work with the wrong password
491        manager
492            .verify(version, wrong_password.clone(), hash.clone())
493            .await
494            .expect_err("Verification should have failed");
495
496        // Verifying with the wrong version doesn't work
497        manager
498            .verify(2, password.clone(), hash.clone())
499            .await
500            .expect_err("Verification should have failed");
501
502        // Upgrading does nothing
503        let res = manager
504            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
505            .await
506            .expect("Failed to verify");
507
508        assert!(res.is_none());
509
510        // Upgrading still verify that the password matches
511        manager
512            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
513            .await
514            .expect_err("Verification should have failed");
515
516        let manager = PasswordManager::new(
517            0,
518            [
519                (2, Hasher::argon2id(None)),
520                (
521                    1,
522                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec())),
523                ),
524            ],
525        )
526        .unwrap();
527
528        // Verifying still works
529        manager
530            .verify(version, password.clone(), hash.clone())
531            .await
532            .expect("Failed to verify");
533
534        // And doesn't work with the wrong password
535        manager
536            .verify(version, wrong_password.clone(), hash.clone())
537            .await
538            .expect_err("Verification should have failed");
539
540        // Upgrading does re-hash
541        let res = manager
542            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
543            .await
544            .expect("Failed to verify");
545
546        assert!(res.is_some());
547        let (version, hash) = res.unwrap();
548
549        assert_eq!(version, 2);
550        insta::assert_snapshot!(hash);
551
552        // Upgrading works with the new hash, but does not upgrade
553        let res = manager
554            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
555            .await
556            .expect("Failed to verify");
557
558        assert!(res.is_none());
559
560        // Upgrading still verify that the password matches
561        manager
562            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
563            .await
564            .expect_err("Verification should have failed");
565
566        // Upgrading still verify that the password matches
567        manager
568            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
569            .await
570            .expect_err("Verification should have failed");
571
572        let manager = PasswordManager::new(
573            0,
574            [
575                (3, Hasher::argon2id(Some(b"a-secret-pepper".to_vec()))),
576                (2, Hasher::argon2id(None)),
577                (
578                    1,
579                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec())),
580                ),
581            ],
582        )
583        .unwrap();
584
585        // Verifying still works
586        manager
587            .verify(version, password.clone(), hash.clone())
588            .await
589            .expect("Failed to verify");
590
591        // And doesn't work with the wrong password
592        manager
593            .verify(version, wrong_password.clone(), hash.clone())
594            .await
595            .expect_err("Verification should have failed");
596
597        // Upgrading does re-hash
598        let res = manager
599            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
600            .await
601            .expect("Failed to verify");
602
603        assert!(res.is_some());
604        let (version, hash) = res.unwrap();
605
606        assert_eq!(version, 3);
607        insta::assert_snapshot!(hash);
608
609        // Upgrading works with the new hash, but does not upgrade
610        let res = manager
611            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
612            .await
613            .expect("Failed to verify");
614
615        assert!(res.is_none());
616
617        // Upgrading still verify that the password matches
618        manager
619            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
620            .await
621            .expect_err("Verification should have failed");
622    }
623}