mas_handlers/
passwords.rs

1// Copyright 2024, 2025 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(
100        &self,
101        password: &str,
102    ) -> Result<bool, PasswordManagerDisabledError> {
103        let inner = self.get_inner()?;
104        let score = zxcvbn(password, &[]);
105        Ok(u8::from(score.score()) >= inner.minimum_complexity)
106    }
107
108    /// Hash a password with the default hashing scheme.
109    /// Returns the version of the hashing scheme used and the hashed password.
110    ///
111    /// # Errors
112    ///
113    /// Returns an error if the hashing failed or if the password manager is
114    /// disabled
115    #[tracing::instrument(name = "passwords.hash", skip_all)]
116    pub async fn hash<R: CryptoRng + RngCore + Send>(
117        &self,
118        rng: R,
119        password: Zeroizing<String>,
120    ) -> Result<(SchemeVersion, String), anyhow::Error> {
121        let inner = self.get_inner()?;
122
123        // Seed a future-local RNG so the RNG passed in parameters doesn't have to be
124        // 'static
125        let rng = rand_chacha::ChaChaRng::from_rng(rng)?;
126        let span = tracing::Span::current();
127
128        // `inner` is being moved in the blocking task, so we need to copy the version
129        // first
130        let version = inner.current_version;
131
132        let hashed = tokio::task::spawn_blocking(move || {
133            span.in_scope(move || inner.current_hasher.hash_blocking(rng, password))
134        })
135        .await??;
136
137        Ok((version, hashed))
138    }
139
140    /// Verify a password hash for the given hashing scheme.
141    ///
142    /// # Errors
143    ///
144    /// Returns an error if the password hash verification failed or if the
145    /// password manager is disabled
146    #[tracing::instrument(name = "passwords.verify", skip_all, fields(%scheme))]
147    pub async fn verify(
148        &self,
149        scheme: SchemeVersion,
150        password: Zeroizing<String>,
151        hashed_password: String,
152    ) -> Result<(), anyhow::Error> {
153        let inner = self.get_inner()?;
154        let span = tracing::Span::current();
155
156        tokio::task::spawn_blocking(move || {
157            span.in_scope(move || {
158                let hasher = if scheme == inner.current_version {
159                    &inner.current_hasher
160                } else {
161                    inner
162                        .other_hashers
163                        .get(&scheme)
164                        .context("Hashing scheme not found")?
165                };
166
167                hasher.verify_blocking(&hashed_password, password)
168            })
169        })
170        .await??;
171
172        Ok(())
173    }
174
175    /// Verify a password hash for the given hashing scheme, and upgrade it on
176    /// the fly, if it was not hashed with the default scheme
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if the password hash verification failed or if the
181    /// password manager is disabled
182    #[tracing::instrument(name = "passwords.verify_and_upgrade", skip_all, fields(%scheme))]
183    pub async fn verify_and_upgrade<R: CryptoRng + RngCore + Send>(
184        &self,
185        rng: R,
186        scheme: SchemeVersion,
187        password: Zeroizing<String>,
188        hashed_password: String,
189    ) -> Result<Option<(SchemeVersion, String)>, anyhow::Error> {
190        let inner = self.get_inner()?;
191
192        // If the current scheme isn't the default one, we also hash with the default
193        // one so that
194        let new_hash_fut: OptionFuture<_> = (scheme != inner.current_version)
195            .then(|| self.hash(rng, password.clone()))
196            .into();
197
198        let verify_fut = self.verify(scheme, password, hashed_password);
199
200        let (new_hash_res, verify_res) = tokio::join!(new_hash_fut, verify_fut);
201        verify_res?;
202
203        let new_hash = new_hash_res.transpose()?;
204
205        Ok(new_hash)
206    }
207}
208
209/// A hashing scheme, with an optional pepper
210pub struct Hasher {
211    algorithm: Algorithm,
212    unicode_normalization: bool,
213    pepper: Option<Vec<u8>>,
214}
215
216impl Hasher {
217    /// Creates a new hashing scheme based on the bcrypt algorithm
218    #[must_use]
219    pub const fn bcrypt(
220        cost: Option<u32>,
221        pepper: Option<Vec<u8>>,
222        unicode_normalization: bool,
223    ) -> Self {
224        let algorithm = Algorithm::Bcrypt { cost };
225        Self {
226            algorithm,
227            unicode_normalization,
228            pepper,
229        }
230    }
231
232    /// Creates a new hashing scheme based on the argon2id algorithm
233    #[must_use]
234    pub const fn argon2id(pepper: Option<Vec<u8>>, unicode_normalization: bool) -> Self {
235        let algorithm = Algorithm::Argon2id;
236        Self {
237            algorithm,
238            unicode_normalization,
239            pepper,
240        }
241    }
242
243    /// Creates a new hashing scheme based on the pbkdf2 algorithm
244    #[must_use]
245    pub const fn pbkdf2(pepper: Option<Vec<u8>>, unicode_normalization: bool) -> Self {
246        let algorithm = Algorithm::Pbkdf2;
247        Self {
248            algorithm,
249            unicode_normalization,
250            pepper,
251        }
252    }
253
254    fn normalize_password(&self, password: Zeroizing<String>) -> Zeroizing<String> {
255        if self.unicode_normalization {
256            // This is the normalization method used by Synapse
257            let normalizer = icu_normalizer::ComposingNormalizer::new_nfkc();
258            Zeroizing::new(normalizer.normalize(&password))
259        } else {
260            password
261        }
262    }
263
264    fn hash_blocking<R: CryptoRng + RngCore>(
265        &self,
266        rng: R,
267        password: Zeroizing<String>,
268    ) -> Result<String, anyhow::Error> {
269        let password = self.normalize_password(password);
270
271        self.algorithm
272            .hash_blocking(rng, password.as_bytes(), self.pepper.as_deref())
273    }
274
275    fn verify_blocking(
276        &self,
277        hashed_password: &str,
278        password: Zeroizing<String>,
279    ) -> Result<(), anyhow::Error> {
280        let password = self.normalize_password(password);
281
282        self.algorithm
283            .verify_blocking(hashed_password, password.as_bytes(), self.pepper.as_deref())
284    }
285}
286
287#[derive(Debug, Clone, Copy)]
288enum Algorithm {
289    Bcrypt { cost: Option<u32> },
290    Argon2id,
291    Pbkdf2,
292}
293
294impl Algorithm {
295    fn hash_blocking<R: CryptoRng + RngCore>(
296        self,
297        mut rng: R,
298        password: &[u8],
299        pepper: Option<&[u8]>,
300    ) -> Result<String, anyhow::Error> {
301        match self {
302            Self::Bcrypt { cost } => {
303                let mut password = Zeroizing::new(password.to_vec());
304                if let Some(pepper) = pepper {
305                    password.extend_from_slice(pepper);
306                }
307
308                let salt = Standard.sample(&mut rng);
309
310                let hashed = bcrypt::hash_with_salt(password, cost.unwrap_or(12), salt)?;
311                Ok(hashed.format_for_version(bcrypt::Version::TwoB))
312            }
313
314            Self::Argon2id => {
315                let algorithm = argon2::Algorithm::default();
316                let version = argon2::Version::default();
317                let params = argon2::Params::default();
318
319                let phf = if let Some(secret) = pepper {
320                    Argon2::new_with_secret(secret, algorithm, version, params)?
321                } else {
322                    Argon2::new(algorithm, version, params)
323                };
324
325                let salt = SaltString::generate(rng);
326                let hashed = phf.hash_password(password.as_ref(), &salt)?;
327                Ok(hashed.to_string())
328            }
329
330            Self::Pbkdf2 => {
331                let mut password = Zeroizing::new(password.to_vec());
332                if let Some(pepper) = pepper {
333                    password.extend_from_slice(pepper);
334                }
335
336                let salt = SaltString::generate(rng);
337                let hashed = Pbkdf2.hash_password(password.as_ref(), &salt)?;
338                Ok(hashed.to_string())
339            }
340        }
341    }
342
343    fn verify_blocking(
344        self,
345        hashed_password: &str,
346        password: &[u8],
347        pepper: Option<&[u8]>,
348    ) -> Result<(), anyhow::Error> {
349        match self {
350            Algorithm::Bcrypt { .. } => {
351                let mut password = Zeroizing::new(password.to_vec());
352                if let Some(pepper) = pepper {
353                    password.extend_from_slice(pepper);
354                }
355
356                let result = bcrypt::verify(password, hashed_password)?;
357                anyhow::ensure!(result, "wrong password");
358            }
359
360            Algorithm::Argon2id => {
361                let algorithm = argon2::Algorithm::default();
362                let version = argon2::Version::default();
363                let params = argon2::Params::default();
364
365                let phf = if let Some(secret) = pepper {
366                    Argon2::new_with_secret(secret, algorithm, version, params)?
367                } else {
368                    Argon2::new(algorithm, version, params)
369                };
370
371                let hashed_password = PasswordHash::new(hashed_password)?;
372
373                phf.verify_password(password.as_ref(), &hashed_password)?;
374            }
375
376            Algorithm::Pbkdf2 => {
377                let mut password = Zeroizing::new(password.to_vec());
378                if let Some(pepper) = pepper {
379                    password.extend_from_slice(pepper);
380                }
381
382                let hashed_password = PasswordHash::new(hashed_password)?;
383
384                Pbkdf2.verify_password(password.as_ref(), &hashed_password)?;
385            }
386        }
387
388        Ok(())
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use rand::SeedableRng;
395
396    use super::*;
397
398    #[test]
399    fn hashing_bcrypt() {
400        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
401        let password = b"hunter2";
402        let password2 = b"wrong-password";
403        let pepper = b"a-secret-pepper";
404        let pepper2 = b"the-wrong-pepper";
405
406        let alg = Algorithm::Bcrypt { cost: Some(10) };
407        // Hash with a pepper
408        let hash = alg
409            .hash_blocking(&mut rng, password, Some(pepper))
410            .expect("Couldn't hash password");
411        insta::assert_snapshot!(hash);
412
413        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
414        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
415        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
416        assert!(alg.verify_blocking(&hash, password, None).is_err());
417
418        // Hash without pepper
419        let hash = alg
420            .hash_blocking(&mut rng, password, None)
421            .expect("Couldn't hash password");
422        insta::assert_snapshot!(hash);
423
424        assert!(alg.verify_blocking(&hash, password, None).is_ok());
425        assert!(alg.verify_blocking(&hash, password2, None).is_err());
426        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
427    }
428
429    #[test]
430    fn hashing_argon2id() {
431        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
432        let password = b"hunter2";
433        let password2 = b"wrong-password";
434        let pepper = b"a-secret-pepper";
435        let pepper2 = b"the-wrong-pepper";
436
437        let alg = Algorithm::Argon2id;
438        // Hash with a pepper
439        let hash = alg
440            .hash_blocking(&mut rng, password, Some(pepper))
441            .expect("Couldn't hash password");
442        insta::assert_snapshot!(hash);
443
444        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
445        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
446        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
447        assert!(alg.verify_blocking(&hash, password, None).is_err());
448
449        // Hash without pepper
450        let hash = alg
451            .hash_blocking(&mut rng, password, None)
452            .expect("Couldn't hash password");
453        insta::assert_snapshot!(hash);
454
455        assert!(alg.verify_blocking(&hash, password, None).is_ok());
456        assert!(alg.verify_blocking(&hash, password2, None).is_err());
457        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
458    }
459
460    #[test]
461    #[ignore = "this is particularly slow (20s+ seconds)"]
462    fn hashing_pbkdf2() {
463        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
464        let password = b"hunter2";
465        let password2 = b"wrong-password";
466        let pepper = b"a-secret-pepper";
467        let pepper2 = b"the-wrong-pepper";
468
469        let alg = Algorithm::Pbkdf2;
470        // Hash with a pepper
471        let hash = alg
472            .hash_blocking(&mut rng, password, Some(pepper))
473            .expect("Couldn't hash password");
474        insta::assert_snapshot!(hash);
475
476        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_ok());
477        assert!(alg.verify_blocking(&hash, password2, Some(pepper)).is_err());
478        assert!(alg.verify_blocking(&hash, password, Some(pepper2)).is_err());
479        assert!(alg.verify_blocking(&hash, password, None).is_err());
480
481        // Hash without pepper
482        let hash = alg
483            .hash_blocking(&mut rng, password, None)
484            .expect("Couldn't hash password");
485        insta::assert_snapshot!(hash);
486
487        assert!(alg.verify_blocking(&hash, password, None).is_ok());
488        assert!(alg.verify_blocking(&hash, password2, None).is_err());
489        assert!(alg.verify_blocking(&hash, password, Some(pepper)).is_err());
490    }
491
492    #[allow(clippy::too_many_lines)]
493    #[tokio::test]
494    async fn hash_verify_and_upgrade() {
495        // Tests the whole password manager, by hashing a password and upgrading it
496        // after changing the hashing schemes. The salt generation is done with a seeded
497        // RNG, so that we can do stable snapshots of hashed passwords
498        let mut rng = rand_chacha::ChaChaRng::seed_from_u64(42);
499        let password = Zeroizing::new("hunter2".to_owned());
500        let wrong_password = Zeroizing::new("wrong-password".to_owned());
501
502        let manager = PasswordManager::new(
503            0,
504            [
505                // Start with one hashing scheme: the one used by synapse, bcrypt + pepper
506                (
507                    1,
508                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec()), false),
509                ),
510            ],
511        )
512        .unwrap();
513
514        let (version, hash) = manager
515            .hash(&mut rng, password.clone())
516            .await
517            .expect("Failed to hash");
518
519        assert_eq!(version, 1);
520        insta::assert_snapshot!(hash);
521
522        // Just verifying works
523        manager
524            .verify(version, password.clone(), hash.clone())
525            .await
526            .expect("Failed to verify");
527
528        // And doesn't work with the wrong password
529        manager
530            .verify(version, wrong_password.clone(), hash.clone())
531            .await
532            .expect_err("Verification should have failed");
533
534        // Verifying with the wrong version doesn't work
535        manager
536            .verify(2, password.clone(), hash.clone())
537            .await
538            .expect_err("Verification should have failed");
539
540        // Upgrading does nothing
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_none());
547
548        // Upgrading still verify that the password matches
549        manager
550            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
551            .await
552            .expect_err("Verification should have failed");
553
554        let manager = PasswordManager::new(
555            0,
556            [
557                (2, Hasher::argon2id(None, false)),
558                (
559                    1,
560                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec()), false),
561                ),
562            ],
563        )
564        .unwrap();
565
566        // Verifying still works
567        manager
568            .verify(version, password.clone(), hash.clone())
569            .await
570            .expect("Failed to verify");
571
572        // And doesn't work with the wrong password
573        manager
574            .verify(version, wrong_password.clone(), hash.clone())
575            .await
576            .expect_err("Verification should have failed");
577
578        // Upgrading does re-hash
579        let res = manager
580            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
581            .await
582            .expect("Failed to verify");
583
584        assert!(res.is_some());
585        let (version, hash) = res.unwrap();
586
587        assert_eq!(version, 2);
588        insta::assert_snapshot!(hash);
589
590        // Upgrading works with the new hash, but does not upgrade
591        let res = manager
592            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
593            .await
594            .expect("Failed to verify");
595
596        assert!(res.is_none());
597
598        // Upgrading still verify that the password matches
599        manager
600            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
601            .await
602            .expect_err("Verification should have failed");
603
604        // Upgrading still verify that the password matches
605        manager
606            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
607            .await
608            .expect_err("Verification should have failed");
609
610        let manager = PasswordManager::new(
611            0,
612            [
613                (
614                    3,
615                    Hasher::argon2id(Some(b"a-secret-pepper".to_vec()), false),
616                ),
617                (2, Hasher::argon2id(None, false)),
618                (
619                    1,
620                    Hasher::bcrypt(Some(10), Some(b"a-secret-pepper".to_vec()), false),
621                ),
622            ],
623        )
624        .unwrap();
625
626        // Verifying still works
627        manager
628            .verify(version, password.clone(), hash.clone())
629            .await
630            .expect("Failed to verify");
631
632        // And doesn't work with the wrong password
633        manager
634            .verify(version, wrong_password.clone(), hash.clone())
635            .await
636            .expect_err("Verification should have failed");
637
638        // Upgrading does re-hash
639        let res = manager
640            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
641            .await
642            .expect("Failed to verify");
643
644        assert!(res.is_some());
645        let (version, hash) = res.unwrap();
646
647        assert_eq!(version, 3);
648        insta::assert_snapshot!(hash);
649
650        // Upgrading works with the new hash, but does not upgrade
651        let res = manager
652            .verify_and_upgrade(&mut rng, version, password.clone(), hash.clone())
653            .await
654            .expect("Failed to verify");
655
656        assert!(res.is_none());
657
658        // Upgrading still verify that the password matches
659        manager
660            .verify_and_upgrade(&mut rng, version, wrong_password.clone(), hash.clone())
661            .await
662            .expect_err("Verification should have failed");
663    }
664}