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