mas_config/sections/
passwords.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37pub struct PasswordsConfig {
38    #[serde(default = "default_enabled")]
40    pub enabled: bool,
41
42    #[serde(default = "default_schemes")]
47    pub schemes: Vec<HashingScheme>,
48
49    #[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(
76        &self,
77        figment: &figment::Figment,
78    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
79        let annotate = |mut error: figment::Error| {
80            error.metadata = figment.find_metadata(Self::PATH.unwrap()).cloned();
81            error.profile = Some(figment::Profile::Default);
82            error.path = vec![Self::PATH.unwrap().to_owned()];
83            error
84        };
85
86        if !self.enabled {
87            return Ok(());
89        }
90
91        if self.schemes.is_empty() {
92            return Err(annotate(figment::Error::from(
93                "Requires at least one password scheme in the config".to_owned(),
94            ))
95            .into());
96        }
97
98        for scheme in &self.schemes {
99            if scheme.secret.is_some() && scheme.secret_file.is_some() {
100                return Err(annotate(figment::Error::from(
101                    "Cannot specify both `secret` and `secret_file`".to_owned(),
102                ))
103                .into());
104            }
105        }
106
107        Ok(())
108    }
109}
110
111impl PasswordsConfig {
112    #[must_use]
114    pub fn enabled(&self) -> bool {
115        self.enabled
116    }
117
118    #[must_use]
121    pub fn minimum_complexity(&self) -> u8 {
122        self.minimum_complexity
123    }
124
125    pub async fn load(
132        &self,
133    ) -> Result<Vec<(u16, Algorithm, Option<u32>, Option<Vec<u8>>, bool)>, anyhow::Error> {
134        let mut schemes: Vec<&HashingScheme> = self.schemes.iter().collect();
135        schemes.sort_unstable_by_key(|a| Reverse(a.version));
136        schemes.dedup_by_key(|a| a.version);
137
138        if schemes.len() != self.schemes.len() {
139            bail!("Multiple password schemes have the same versions");
141        }
142
143        if schemes.is_empty() {
144            bail!("Requires at least one password scheme in the config");
145        }
146
147        let mut mapped_result = Vec::with_capacity(schemes.len());
148
149        for scheme in schemes {
150            let secret = match (&scheme.secret, &scheme.secret_file) {
151                (Some(secret), None) => Some(secret.clone().into_bytes()),
152                (None, Some(secret_file)) => {
153                    let secret = tokio::fs::read(secret_file).await?;
154                    Some(secret)
155                }
156                (Some(_), Some(_)) => bail!("Cannot specify both `secret` and `secret_file`"),
157                (None, None) => None,
158            };
159
160            mapped_result.push((
161                scheme.version,
162                scheme.algorithm,
163                scheme.cost,
164                secret,
165                scheme.unicode_normalization,
166            ));
167        }
168
169        Ok(mapped_result)
170    }
171}
172
173#[allow(clippy::trivially_copy_pass_by_ref)]
174const fn is_default_false(value: &bool) -> bool {
175    !*value
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180pub struct HashingScheme {
181    pub version: u16,
184
185    pub algorithm: Algorithm,
187
188    #[serde(default, skip_serializing_if = "is_default_false")]
194    pub unicode_normalization: bool,
195
196    #[serde(skip_serializing_if = "Option::is_none")]
198    #[schemars(default = "default_bcrypt_cost")]
199    pub cost: Option<u32>,
200
201    #[serde(skip_serializing_if = "Option::is_none")]
204    pub secret: Option<String>,
205
206    #[serde(skip_serializing_if = "Option::is_none")]
208    #[schemars(with = "Option<String>")]
209    pub secret_file: Option<Utf8PathBuf>,
210}
211
212#[allow(clippy::unnecessary_wraps)]
213fn default_bcrypt_cost() -> Option<u32> {
214    Some(12)
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
219#[serde(rename_all = "lowercase")]
220pub enum Algorithm {
221    Bcrypt,
223
224    #[default]
226    Argon2id,
227
228    Pbkdf2,
230}