mas_config/sections/
secrets.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 OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use std::borrow::Cow;
8
9use anyhow::{Context, bail};
10use camino::Utf8PathBuf;
11use futures_util::future::{try_join, try_join_all};
12use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
13use mas_keystore::{Encrypter, Keystore, PrivateKey};
14use rand::{
15    Rng, SeedableRng,
16    distributions::{Alphanumeric, DistString, Standard},
17    prelude::Distribution as _,
18};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use serde_with::serde_as;
22use tokio::task;
23use tracing::info;
24
25use super::ConfigurationSection;
26
27fn example_secret() -> &'static str {
28    "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
29}
30
31/// Password config option.
32///
33/// It either holds the password value directly or references a file where the
34/// password is stored.
35#[derive(Clone, Debug)]
36pub enum Password {
37    File(Utf8PathBuf),
38    Value(String),
39}
40
41/// Password fields as serialized in JSON.
42#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
43struct PasswordRaw {
44    #[schemars(with = "Option<String>")]
45    #[serde(skip_serializing_if = "Option::is_none")]
46    password_file: Option<Utf8PathBuf>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    password: Option<String>,
49}
50
51impl TryFrom<PasswordRaw> for Option<Password> {
52    type Error = anyhow::Error;
53
54    fn try_from(value: PasswordRaw) -> Result<Self, Self::Error> {
55        match (value.password, value.password_file) {
56            (None, None) => Ok(None),
57            (None, Some(path)) => Ok(Some(Password::File(path))),
58            (Some(password), None) => Ok(Some(Password::Value(password))),
59            (Some(_), Some(_)) => bail!("Cannot specify both `password` and `password_file`"),
60        }
61    }
62}
63
64impl From<Option<Password>> for PasswordRaw {
65    fn from(value: Option<Password>) -> Self {
66        match value {
67            Some(Password::File(path)) => PasswordRaw {
68                password_file: Some(path),
69                password: None,
70            },
71            Some(Password::Value(password)) => PasswordRaw {
72                password_file: None,
73                password: Some(password),
74            },
75            None => PasswordRaw {
76                password_file: None,
77                password: None,
78            },
79        }
80    }
81}
82
83/// Key config option.
84///
85/// It either holds the key value directly or references a file where the key is
86/// stored.
87#[derive(Clone, Debug)]
88pub enum Key {
89    File(Utf8PathBuf),
90    Value(String),
91}
92
93/// Key fields as serialized in JSON.
94#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
95struct KeyRaw {
96    #[schemars(with = "Option<String>")]
97    #[serde(skip_serializing_if = "Option::is_none")]
98    key_file: Option<Utf8PathBuf>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    key: Option<String>,
101}
102
103impl TryFrom<KeyRaw> for Key {
104    type Error = anyhow::Error;
105
106    fn try_from(value: KeyRaw) -> Result<Key, Self::Error> {
107        match (value.key, value.key_file) {
108            (None, None) => bail!("Missing `key` or `key_file`"),
109            (None, Some(path)) => Ok(Key::File(path)),
110            (Some(key), None) => Ok(Key::Value(key)),
111            (Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"),
112        }
113    }
114}
115
116impl From<Key> for KeyRaw {
117    fn from(value: Key) -> Self {
118        match value {
119            Key::File(path) => KeyRaw {
120                key_file: Some(path),
121                key: None,
122            },
123            Key::Value(key) => KeyRaw {
124                key_file: None,
125                key: Some(key),
126            },
127        }
128    }
129}
130
131/// A single key with its key ID and optional password.
132#[serde_as]
133#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
134pub struct KeyConfig {
135    kid: String,
136
137    #[schemars(with = "PasswordRaw")]
138    #[serde_as(as = "serde_with::TryFromInto<PasswordRaw>")]
139    #[serde(flatten)]
140    password: Option<Password>,
141
142    #[schemars(with = "KeyRaw")]
143    #[serde_as(as = "serde_with::TryFromInto<KeyRaw>")]
144    #[serde(flatten)]
145    key: Key,
146}
147
148impl KeyConfig {
149    /// Returns the password in case any is provided.
150    ///
151    /// If `password_file` was given, the password is read from that file.
152    async fn password(&self) -> anyhow::Result<Option<Cow<[u8]>>> {
153        Ok(match &self.password {
154            Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read(path).await?)),
155            Some(Password::Value(password)) => Some(Cow::Borrowed(password.as_bytes())),
156            None => None,
157        })
158    }
159
160    /// Returns the key.
161    ///
162    /// If `key_file` was given, the key is read from that file.
163    async fn key(&self) -> anyhow::Result<Cow<[u8]>> {
164        Ok(match &self.key {
165            Key::File(path) => Cow::Owned(tokio::fs::read(path).await?),
166            Key::Value(key) => Cow::Borrowed(key.as_bytes()),
167        })
168    }
169
170    /// Returns the JSON Web Key derived from this key config.
171    ///
172    /// Password and/or key are read from file if they’re given as path.
173    async fn json_web_key(&self) -> anyhow::Result<JsonWebKey<mas_keystore::PrivateKey>> {
174        let (key, password) = try_join(self.key(), self.password()).await?;
175
176        let private_key = match password {
177            Some(password) => PrivateKey::load_encrypted(&key, password)?,
178            None => PrivateKey::load(&key)?,
179        };
180
181        Ok(JsonWebKey::new(private_key)
182            .with_kid(self.kid.clone())
183            .with_use(mas_iana::jose::JsonWebKeyUse::Sig))
184    }
185}
186
187/// Encryption config option.
188#[derive(Debug, Clone)]
189pub enum Encryption {
190    File(Utf8PathBuf),
191    Value([u8; 32]),
192}
193
194/// Encryption fields as serialized in JSON.
195#[serde_as]
196#[derive(JsonSchema, Serialize, Deserialize, Debug, Clone)]
197struct EncryptionRaw {
198    /// File containing the encryption key for secure cookies.
199    #[schemars(with = "Option<String>")]
200    #[serde(skip_serializing_if = "Option::is_none")]
201    encryption_file: Option<Utf8PathBuf>,
202
203    /// Encryption key for secure cookies.
204    #[schemars(
205        with = "Option<String>",
206        regex(pattern = r"[0-9a-fA-F]{64}"),
207        example = "example_secret"
208    )]
209    #[serde_as(as = "Option<serde_with::hex::Hex>")]
210    #[serde(skip_serializing_if = "Option::is_none")]
211    encryption: Option<[u8; 32]>,
212}
213
214impl TryFrom<EncryptionRaw> for Encryption {
215    type Error = anyhow::Error;
216
217    fn try_from(value: EncryptionRaw) -> Result<Encryption, Self::Error> {
218        match (value.encryption, value.encryption_file) {
219            (None, None) => bail!("Missing `encryption` or `encryption_file`"),
220            (None, Some(path)) => Ok(Encryption::File(path)),
221            (Some(encryption), None) => Ok(Encryption::Value(encryption)),
222            (Some(_), Some(_)) => bail!("Cannot specify both `encryption` and `encryption_file`"),
223        }
224    }
225}
226
227impl From<Encryption> for EncryptionRaw {
228    fn from(value: Encryption) -> Self {
229        match value {
230            Encryption::File(path) => EncryptionRaw {
231                encryption_file: Some(path),
232                encryption: None,
233            },
234            Encryption::Value(encryption) => EncryptionRaw {
235                encryption_file: None,
236                encryption: Some(encryption),
237            },
238        }
239    }
240}
241
242/// Application secrets
243#[serde_as]
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
245pub struct SecretsConfig {
246    /// Encryption key for secure cookies
247    #[schemars(with = "EncryptionRaw")]
248    #[serde_as(as = "serde_with::TryFromInto<EncryptionRaw>")]
249    #[serde(flatten)]
250    encryption: Encryption,
251
252    /// List of private keys to use for signing and encrypting payloads
253    #[serde(default)]
254    keys: Vec<KeyConfig>,
255}
256
257impl SecretsConfig {
258    /// Derive a signing and verifying keystore out of the config
259    ///
260    /// # Errors
261    ///
262    /// Returns an error when a key could not be imported
263    #[tracing::instrument(name = "secrets.load", skip_all)]
264    pub async fn key_store(&self) -> anyhow::Result<Keystore> {
265        let web_keys = try_join_all(self.keys.iter().map(KeyConfig::json_web_key)).await?;
266
267        Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
268    }
269
270    /// Derive an [`Encrypter`] out of the config
271    ///
272    /// # Errors
273    ///
274    /// Returns an error when the Encryptor can not be created.
275    pub async fn encrypter(&self) -> anyhow::Result<Encrypter> {
276        Ok(Encrypter::new(&self.encryption().await?))
277    }
278
279    /// Returns the encryption secret.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error when the encryption secret could not be read from file.
284    pub async fn encryption(&self) -> anyhow::Result<[u8; 32]> {
285        // Read the encryption secret either embedded in the config file or on disk
286        match self.encryption {
287            Encryption::Value(encryption) => Ok(encryption),
288            Encryption::File(ref path) => {
289                let mut bytes = [0; 32];
290                let content = tokio::fs::read(path).await?;
291                hex::decode_to_slice(content, &mut bytes).context(
292                    "Content of `encryption_file` must contain hex characters \
293                    encoding exactly 32 bytes",
294                )?;
295                Ok(bytes)
296            }
297        }
298    }
299}
300
301impl ConfigurationSection for SecretsConfig {
302    const PATH: Option<&'static str> = Some("secrets");
303}
304
305impl SecretsConfig {
306    #[expect(clippy::similar_names, reason = "Key type names are very similar")]
307    #[tracing::instrument(skip_all)]
308    pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
309    where
310        R: Rng + Send,
311    {
312        info!("Generating keys...");
313
314        let span = tracing::info_span!("rsa");
315        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
316        let rsa_key = task::spawn_blocking(move || {
317            let _entered = span.enter();
318            let ret = PrivateKey::generate_rsa(key_rng).unwrap();
319            info!("Done generating RSA key");
320            ret
321        })
322        .await
323        .context("could not join blocking task")?;
324        let rsa_key = KeyConfig {
325            kid: Alphanumeric.sample_string(&mut rng, 10),
326            password: None,
327            key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
328        };
329
330        let span = tracing::info_span!("ec_p256");
331        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
332        let ec_p256_key = task::spawn_blocking(move || {
333            let _entered = span.enter();
334            let ret = PrivateKey::generate_ec_p256(key_rng);
335            info!("Done generating EC P-256 key");
336            ret
337        })
338        .await
339        .context("could not join blocking task")?;
340        let ec_p256_key = KeyConfig {
341            kid: Alphanumeric.sample_string(&mut rng, 10),
342            password: None,
343            key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
344        };
345
346        let span = tracing::info_span!("ec_p384");
347        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
348        let ec_p384_key = task::spawn_blocking(move || {
349            let _entered = span.enter();
350            let ret = PrivateKey::generate_ec_p384(key_rng);
351            info!("Done generating EC P-384 key");
352            ret
353        })
354        .await
355        .context("could not join blocking task")?;
356        let ec_p384_key = KeyConfig {
357            kid: Alphanumeric.sample_string(&mut rng, 10),
358            password: None,
359            key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
360        };
361
362        let span = tracing::info_span!("ec_k256");
363        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
364        let ec_k256_key = task::spawn_blocking(move || {
365            let _entered = span.enter();
366            let ret = PrivateKey::generate_ec_k256(key_rng);
367            info!("Done generating EC secp256k1 key");
368            ret
369        })
370        .await
371        .context("could not join blocking task")?;
372        let ec_k256_key = KeyConfig {
373            kid: Alphanumeric.sample_string(&mut rng, 10),
374            password: None,
375            key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
376        };
377
378        Ok(Self {
379            encryption: Encryption::Value(Standard.sample(&mut rng)),
380            keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key],
381        })
382    }
383
384    pub(crate) fn test() -> Self {
385        let rsa_key = KeyConfig {
386            kid: "abcdef".to_owned(),
387            password: None,
388            key: Key::Value(
389                indoc::indoc! {r"
390                  -----BEGIN PRIVATE KEY-----
391                  MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
392                  QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
393                  scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
394                  3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
395                  vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
396                  N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
397                  tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
398                  Gh7BNzCeN+D6
399                  -----END PRIVATE KEY-----
400                "}
401                .to_owned(),
402            ),
403        };
404        let ecdsa_key = KeyConfig {
405            kid: "ghijkl".to_owned(),
406            password: None,
407            key: Key::Value(
408                indoc::indoc! {r"
409                  -----BEGIN PRIVATE KEY-----
410                  MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
411                  NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
412                  OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
413                  -----END PRIVATE KEY-----
414                "}
415                .to_owned(),
416            ),
417        };
418
419        Self {
420            encryption: Encryption::Value([0xEA; 32]),
421            keys: vec![rsa_key, ecdsa_key],
422        }
423    }
424}