mas_config/sections/
secrets.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7use std::borrow::Cow;
8
9use anyhow::{Context, bail};
10use camino::Utf8PathBuf;
11use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
12use mas_keystore::{Encrypter, Keystore, PrivateKey};
13use rand::{
14    Rng, SeedableRng,
15    distributions::{Alphanumeric, DistString, Standard},
16    prelude::Distribution as _,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use serde_with::serde_as;
21use tokio::task;
22use tracing::info;
23
24use super::ConfigurationSection;
25
26fn example_secret() -> &'static str {
27    "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
28}
29
30#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
31pub struct KeyConfig {
32    kid: String,
33
34    #[serde(skip_serializing_if = "Option::is_none")]
35    password: Option<String>,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    #[schemars(with = "Option<String>")]
39    password_file: Option<Utf8PathBuf>,
40
41    #[serde(skip_serializing_if = "Option::is_none")]
42    key: Option<String>,
43
44    #[serde(skip_serializing_if = "Option::is_none")]
45    #[schemars(with = "Option<String>")]
46    key_file: Option<Utf8PathBuf>,
47}
48
49/// Application secrets
50#[serde_as]
51#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
52pub struct SecretsConfig {
53    /// Encryption key for secure cookies
54    #[schemars(
55        with = "String",
56        regex(pattern = r"[0-9a-fA-F]{64}"),
57        example = "example_secret"
58    )]
59    #[serde_as(as = "serde_with::hex::Hex")]
60    pub encryption: [u8; 32],
61
62    /// List of private keys to use for signing and encrypting payloads
63    #[serde(default)]
64    keys: Vec<KeyConfig>,
65}
66
67impl SecretsConfig {
68    /// Derive a signing and verifying keystore out of the config
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when a key could not be imported
73    #[tracing::instrument(name = "secrets.load", skip_all, err(Debug))]
74    pub async fn key_store(&self) -> anyhow::Result<Keystore> {
75        let mut keys = Vec::with_capacity(self.keys.len());
76        for item in &self.keys {
77            let password = match (&item.password, &item.password_file) {
78                (None, None) => None,
79                (Some(_), Some(_)) => {
80                    bail!("Cannot specify both `password` and `password_file`")
81                }
82                (Some(password), None) => Some(Cow::Borrowed(password)),
83                (None, Some(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)),
84            };
85
86            // Read the key either embedded in the config file or on disk
87            let key = match (&item.key, &item.key_file) {
88                (None, None) => bail!("Missing `key` or `key_file`"),
89                (Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"),
90                (Some(key), None) => {
91                    // If the key was embedded in the config file, assume it is formatted as PEM
92                    if let Some(password) = password {
93                        PrivateKey::load_encrypted_pem(key, password.as_bytes())?
94                    } else {
95                        PrivateKey::load_pem(key)?
96                    }
97                }
98                (None, Some(path)) => {
99                    // When reading from disk, it might be either PEM or DER. `PrivateKey::load*`
100                    // will try both.
101                    let key = tokio::fs::read(path).await?;
102                    if let Some(password) = password {
103                        PrivateKey::load_encrypted(&key, password.as_bytes())?
104                    } else {
105                        PrivateKey::load(&key)?
106                    }
107                }
108            };
109
110            let key = JsonWebKey::new(key)
111                .with_kid(item.kid.clone())
112                .with_use(mas_iana::jose::JsonWebKeyUse::Sig);
113            keys.push(key);
114        }
115
116        let keys = JsonWebKeySet::new(keys);
117        Ok(Keystore::new(keys))
118    }
119
120    /// Derive an [`Encrypter`] out of the config
121    #[must_use]
122    pub fn encrypter(&self) -> Encrypter {
123        Encrypter::new(&self.encryption)
124    }
125}
126
127impl ConfigurationSection for SecretsConfig {
128    const PATH: Option<&'static str> = Some("secrets");
129
130    fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
131        for (index, key) in self.keys.iter().enumerate() {
132            let annotate = |mut error: figment::Error| {
133                error.metadata = figment
134                    .find_metadata(&format!("{root}.keys", root = Self::PATH.unwrap()))
135                    .cloned();
136                error.profile = Some(figment::Profile::Default);
137                error.path = vec![
138                    Self::PATH.unwrap().to_owned(),
139                    "keys".to_owned(),
140                    index.to_string(),
141                ];
142                Err(error)
143            };
144
145            if key.key.is_none() && key.key_file.is_none() {
146                return annotate(figment::Error::from(
147                    "Missing `key` or `key_file`".to_owned(),
148                ));
149            }
150
151            if key.key.is_some() && key.key_file.is_some() {
152                return annotate(figment::Error::from(
153                    "Cannot specify both `key` and `key_file`".to_owned(),
154                ));
155            }
156
157            if key.password.is_some() && key.password_file.is_some() {
158                return annotate(figment::Error::from(
159                    "Cannot specify both `password` and `password_file`".to_owned(),
160                ));
161            }
162        }
163
164        Ok(())
165    }
166}
167
168impl SecretsConfig {
169    #[tracing::instrument(skip_all)]
170    pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
171    where
172        R: Rng + Send,
173    {
174        info!("Generating keys...");
175
176        let span = tracing::info_span!("rsa");
177        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
178        let rsa_key = task::spawn_blocking(move || {
179            let _entered = span.enter();
180            let ret = PrivateKey::generate_rsa(key_rng).unwrap();
181            info!("Done generating RSA key");
182            ret
183        })
184        .await
185        .context("could not join blocking task")?;
186        let rsa_key = KeyConfig {
187            kid: Alphanumeric.sample_string(&mut rng, 10),
188            password: None,
189            password_file: None,
190            key: Some(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
191            key_file: None,
192        };
193
194        let span = tracing::info_span!("ec_p256");
195        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
196        let ec_p256_key = task::spawn_blocking(move || {
197            let _entered = span.enter();
198            let ret = PrivateKey::generate_ec_p256(key_rng);
199            info!("Done generating EC P-256 key");
200            ret
201        })
202        .await
203        .context("could not join blocking task")?;
204        let ec_p256_key = KeyConfig {
205            kid: Alphanumeric.sample_string(&mut rng, 10),
206            password: None,
207            password_file: None,
208            key: Some(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
209            key_file: None,
210        };
211
212        let span = tracing::info_span!("ec_p384");
213        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
214        let ec_p384_key = task::spawn_blocking(move || {
215            let _entered = span.enter();
216            let ret = PrivateKey::generate_ec_p384(key_rng);
217            info!("Done generating EC P-256 key");
218            ret
219        })
220        .await
221        .context("could not join blocking task")?;
222        let ec_p384_key = KeyConfig {
223            kid: Alphanumeric.sample_string(&mut rng, 10),
224            password: None,
225            password_file: None,
226            key: Some(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
227            key_file: None,
228        };
229
230        let span = tracing::info_span!("ec_k256");
231        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
232        let ec_k256_key = task::spawn_blocking(move || {
233            let _entered = span.enter();
234            let ret = PrivateKey::generate_ec_k256(key_rng);
235            info!("Done generating EC secp256k1 key");
236            ret
237        })
238        .await
239        .context("could not join blocking task")?;
240        let ec_k256_key = KeyConfig {
241            kid: Alphanumeric.sample_string(&mut rng, 10),
242            password: None,
243            password_file: None,
244            key: Some(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
245            key_file: None,
246        };
247
248        Ok(Self {
249            encryption: Standard.sample(&mut rng),
250            keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key],
251        })
252    }
253
254    pub(crate) fn test() -> Self {
255        let rsa_key = KeyConfig {
256            kid: "abcdef".to_owned(),
257            password: None,
258            password_file: None,
259            key: Some(
260                indoc::indoc! {r"
261                  -----BEGIN PRIVATE KEY-----
262                  MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
263                  QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
264                  scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
265                  3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
266                  vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
267                  N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
268                  tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
269                  Gh7BNzCeN+D6
270                  -----END PRIVATE KEY-----
271                "}
272                .to_owned(),
273            ),
274            key_file: None,
275        };
276        let ecdsa_key = KeyConfig {
277            kid: "ghijkl".to_owned(),
278            password: None,
279            password_file: None,
280            key: Some(
281                indoc::indoc! {r"
282                  -----BEGIN PRIVATE KEY-----
283                  MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
284                  NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
285                  OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
286                  -----END PRIVATE KEY-----
287                "}
288                .to_owned(),
289            ),
290            key_file: None,
291        };
292
293        Self {
294            encryption: [0xEA; 32],
295            keys: vec![rsa_key, ecdsa_key],
296        }
297    }
298}