mas_config/sections/
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::cmp::Reverse;
8
9use anyhow::bail;
10use camino::Utf8PathBuf;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::ConfigurationSection;
15
16fn default_schemes() -> Vec<HashingScheme> {
17    vec![HashingScheme {
18        version: 1,
19        algorithm: Algorithm::default(),
20        cost: None,
21        secret: None,
22        secret_file: None,
23    }]
24}
25
26fn default_enabled() -> bool {
27    true
28}
29
30fn default_minimum_complexity() -> u8 {
31    3
32}
33
34/// User password hashing config
35#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
36pub struct PasswordsConfig {
37    /// Whether password-based authentication is enabled
38    #[serde(default = "default_enabled")]
39    pub enabled: bool,
40
41    /// The hashing schemes to use for hashing and validating passwords
42    ///
43    /// The hashing scheme with the highest version number will be used for
44    /// hashing new passwords.
45    #[serde(default = "default_schemes")]
46    pub schemes: Vec<HashingScheme>,
47
48    /// Score between 0 and 4 determining the minimum allowed password
49    /// complexity. Scores are based on the ESTIMATED number of guesses
50    /// needed to guess the password.
51    ///
52    /// - 0: less than 10^2 (100)
53    /// - 1: less than 10^4 (10'000)
54    /// - 2: less than 10^6 (1'000'000)
55    /// - 3: less than 10^8 (100'000'000)
56    /// - 4: any more than that
57    #[serde(default = "default_minimum_complexity")]
58    minimum_complexity: u8,
59}
60
61impl Default for PasswordsConfig {
62    fn default() -> Self {
63        Self {
64            enabled: default_enabled(),
65            schemes: default_schemes(),
66            minimum_complexity: default_minimum_complexity(),
67        }
68    }
69}
70
71impl ConfigurationSection for PasswordsConfig {
72    const PATH: Option<&'static str> = Some("passwords");
73
74    fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
75        let annotate = |mut error: figment::Error| {
76            error.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
77            error.profile = Some(figment::Profile::Default);
78            error.path = vec![Self::PATH.unwrap().to_owned()];
79            Err(error)
80        };
81
82        if !self.enabled {
83            // Skip validation if password-based authentication is disabled
84            return Ok(());
85        }
86
87        if self.schemes.is_empty() {
88            return annotate(figment::Error::from(
89                "Requires at least one password scheme in the config".to_owned(),
90            ));
91        }
92
93        for scheme in &self.schemes {
94            if scheme.secret.is_some() && scheme.secret_file.is_some() {
95                return annotate(figment::Error::from(
96                    "Cannot specify both `secret` and `secret_file`".to_owned(),
97                ));
98            }
99        }
100
101        Ok(())
102    }
103}
104
105impl PasswordsConfig {
106    /// Whether password-based authentication is enabled
107    #[must_use]
108    pub fn enabled(&self) -> bool {
109        self.enabled
110    }
111
112    /// Minimum complexity of passwords, from 0 to 4, according to the zxcvbn
113    /// scorer.
114    #[must_use]
115    pub fn minimum_complexity(&self) -> u8 {
116        self.minimum_complexity
117    }
118
119    /// Load the password hashing schemes defined by the config
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the config is invalid, or if the secret file could
124    /// not be read.
125    pub async fn load(
126        &self,
127    ) -> Result<Vec<(u16, Algorithm, Option<u32>, Option<Vec<u8>>)>, anyhow::Error> {
128        let mut schemes: Vec<&HashingScheme> = self.schemes.iter().collect();
129        schemes.sort_unstable_by_key(|a| Reverse(a.version));
130        schemes.dedup_by_key(|a| a.version);
131
132        if schemes.len() != self.schemes.len() {
133            // Some schemes had duplicated versions
134            bail!("Multiple password schemes have the same versions");
135        }
136
137        if schemes.is_empty() {
138            bail!("Requires at least one password scheme in the config");
139        }
140
141        let mut mapped_result = Vec::with_capacity(schemes.len());
142
143        for scheme in schemes {
144            let secret = match (&scheme.secret, &scheme.secret_file) {
145                (Some(secret), None) => Some(secret.clone().into_bytes()),
146                (None, Some(secret_file)) => {
147                    let secret = tokio::fs::read(secret_file).await?;
148                    Some(secret)
149                }
150                (Some(_), Some(_)) => bail!("Cannot specify both `secret` and `secret_file`"),
151                (None, None) => None,
152            };
153
154            mapped_result.push((scheme.version, scheme.algorithm, scheme.cost, secret));
155        }
156
157        Ok(mapped_result)
158    }
159}
160
161/// Parameters for a password hashing scheme
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
163pub struct HashingScheme {
164    /// The version of the hashing scheme. They must be unique, and the highest
165    /// version will be used for hashing new passwords.
166    pub version: u16,
167
168    /// The hashing algorithm to use
169    pub algorithm: Algorithm,
170
171    /// Cost for the bcrypt algorithm
172    #[serde(skip_serializing_if = "Option::is_none")]
173    #[schemars(default = "default_bcrypt_cost")]
174    pub cost: Option<u32>,
175
176    /// An optional secret to use when hashing passwords. This makes it harder
177    /// to brute-force the passwords in case of a database leak.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub secret: Option<String>,
180
181    /// Same as `secret`, but read from a file.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    #[schemars(with = "Option<String>")]
184    pub secret_file: Option<Utf8PathBuf>,
185}
186
187#[allow(clippy::unnecessary_wraps)]
188fn default_bcrypt_cost() -> Option<u32> {
189    Some(12)
190}
191
192/// A hashing algorithm
193#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
194#[serde(rename_all = "lowercase")]
195pub enum Algorithm {
196    /// bcrypt
197    Bcrypt,
198
199    /// argon2id
200    #[default]
201    Argon2id,
202
203    /// PBKDF2
204    Pbkdf2,
205}