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