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