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