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    #[tracing::instrument(skip_all)]
307    pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
308    where
309        R: Rng + Send,
310    {
311        info!("Generating keys...");
312
313        let span = tracing::info_span!("rsa");
314        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
315        let rsa_key = task::spawn_blocking(move || {
316            let _entered = span.enter();
317            let ret = PrivateKey::generate_rsa(key_rng).unwrap();
318            info!("Done generating RSA key");
319            ret
320        })
321        .await
322        .context("could not join blocking task")?;
323        let rsa_key = KeyConfig {
324            kid: Alphanumeric.sample_string(&mut rng, 10),
325            password: None,
326            key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
327        };
328
329        let span = tracing::info_span!("ec_p256");
330        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
331        let ec_p256_key = task::spawn_blocking(move || {
332            let _entered = span.enter();
333            let ret = PrivateKey::generate_ec_p256(key_rng);
334            info!("Done generating EC P-256 key");
335            ret
336        })
337        .await
338        .context("could not join blocking task")?;
339        let ec_p256_key = KeyConfig {
340            kid: Alphanumeric.sample_string(&mut rng, 10),
341            password: None,
342            key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
343        };
344
345        let span = tracing::info_span!("ec_p384");
346        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
347        let ec_p384_key = task::spawn_blocking(move || {
348            let _entered = span.enter();
349            let ret = PrivateKey::generate_ec_p384(key_rng);
350            info!("Done generating EC P-256 key");
351            ret
352        })
353        .await
354        .context("could not join blocking task")?;
355        let ec_p384_key = KeyConfig {
356            kid: Alphanumeric.sample_string(&mut rng, 10),
357            password: None,
358            key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
359        };
360
361        let span = tracing::info_span!("ec_k256");
362        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
363        let ec_k256_key = task::spawn_blocking(move || {
364            let _entered = span.enter();
365            let ret = PrivateKey::generate_ec_k256(key_rng);
366            info!("Done generating EC secp256k1 key");
367            ret
368        })
369        .await
370        .context("could not join blocking task")?;
371        let ec_k256_key = KeyConfig {
372            kid: Alphanumeric.sample_string(&mut rng, 10),
373            password: None,
374            key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
375        };
376
377        Ok(Self {
378            encryption: Encryption::Value(Standard.sample(&mut rng)),
379            keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key],
380        })
381    }
382
383    pub(crate) fn test() -> Self {
384        let rsa_key = KeyConfig {
385            kid: "abcdef".to_owned(),
386            password: None,
387            key: Key::Value(
388                indoc::indoc! {r"
389                  -----BEGIN PRIVATE KEY-----
390                  MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
391                  QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
392                  scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
393                  3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
394                  vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
395                  N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
396                  tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
397                  Gh7BNzCeN+D6
398                  -----END PRIVATE KEY-----
399                "}
400                .to_owned(),
401            ),
402        };
403        let ecdsa_key = KeyConfig {
404            kid: "ghijkl".to_owned(),
405            password: None,
406            key: Key::Value(
407                indoc::indoc! {r"
408                  -----BEGIN PRIVATE KEY-----
409                  MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
410                  NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
411                  OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
412                  -----END PRIVATE KEY-----
413                "}
414                .to_owned(),
415            ),
416        };
417
418        Self {
419            encryption: Encryption::Value([0xEA; 32]),
420            keys: vec![rsa_key, ecdsa_key],
421        }
422    }
423}