Skip to main content

mas_config/sections/
secrets.rs

1// Copyright 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::borrow::Cow;
9
10use anyhow::{Context, bail};
11use camino::Utf8PathBuf;
12use futures_util::future::{try_join, try_join_all};
13use mas_jose::jwk::{JsonWebKey, JsonWebKeySet, Thumbprint};
14use mas_keystore::{Encrypter, Keystore, PrivateKey};
15use rand::{Rng, SeedableRng, distributions::Standard, prelude::Distribution as _};
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_with::serde_as;
19use tokio::task;
20use tracing::info;
21
22use super::ConfigurationSection;
23
24/// Password config option.
25///
26/// It either holds the password value directly or references a file where the
27/// password is stored.
28#[derive(Clone, Debug)]
29pub enum Password {
30    File(Utf8PathBuf),
31    Value(String),
32}
33
34/// Password fields as serialized in JSON.
35#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
36struct PasswordRaw {
37    #[schemars(with = "Option<String>")]
38    #[serde(skip_serializing_if = "Option::is_none")]
39    password_file: Option<Utf8PathBuf>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    password: Option<String>,
42}
43
44impl TryFrom<PasswordRaw> for Option<Password> {
45    type Error = anyhow::Error;
46
47    fn try_from(value: PasswordRaw) -> Result<Self, Self::Error> {
48        match (value.password, value.password_file) {
49            (None, None) => Ok(None),
50            (None, Some(path)) => Ok(Some(Password::File(path))),
51            (Some(password), None) => Ok(Some(Password::Value(password))),
52            (Some(_), Some(_)) => bail!("Cannot specify both `password` and `password_file`"),
53        }
54    }
55}
56
57impl From<Option<Password>> for PasswordRaw {
58    fn from(value: Option<Password>) -> Self {
59        match value {
60            Some(Password::File(path)) => PasswordRaw {
61                password_file: Some(path),
62                password: None,
63            },
64            Some(Password::Value(password)) => PasswordRaw {
65                password_file: None,
66                password: Some(password),
67            },
68            None => PasswordRaw {
69                password_file: None,
70                password: None,
71            },
72        }
73    }
74}
75
76/// Key config option.
77///
78/// It either holds the key value directly or references a file where the key is
79/// stored.
80#[derive(Clone, Debug)]
81pub enum Key {
82    File(Utf8PathBuf),
83    Value(String),
84}
85
86/// Key fields as serialized in JSON.
87#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
88struct KeyRaw {
89    #[schemars(with = "Option<String>")]
90    #[serde(skip_serializing_if = "Option::is_none")]
91    key_file: Option<Utf8PathBuf>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    key: Option<String>,
94}
95
96impl TryFrom<KeyRaw> for Key {
97    type Error = anyhow::Error;
98
99    fn try_from(value: KeyRaw) -> Result<Key, Self::Error> {
100        match (value.key, value.key_file) {
101            (None, None) => bail!("Missing `key` or `key_file`"),
102            (None, Some(path)) => Ok(Key::File(path)),
103            (Some(key), None) => Ok(Key::Value(key)),
104            (Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"),
105        }
106    }
107}
108
109impl From<Key> for KeyRaw {
110    fn from(value: Key) -> Self {
111        match value {
112            Key::File(path) => KeyRaw {
113                key_file: Some(path),
114                key: None,
115            },
116            Key::Value(key) => KeyRaw {
117                key_file: None,
118                key: Some(key),
119            },
120        }
121    }
122}
123
124/// A single key with its key ID and optional password.
125#[serde_as]
126#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
127pub struct KeyConfig {
128    /// The key ID `kid` of the key as used by JWKs.
129    ///
130    /// If not given, `kid` will be the key’s RFC 7638 JWK Thumbprint.
131    #[serde(skip_serializing_if = "Option::is_none")]
132    kid: Option<String>,
133
134    #[schemars(with = "PasswordRaw")]
135    #[serde_as(as = "serde_with::TryFromInto<PasswordRaw>")]
136    #[serde(flatten)]
137    password: Option<Password>,
138
139    #[schemars(with = "KeyRaw")]
140    #[serde_as(as = "serde_with::TryFromInto<KeyRaw>")]
141    #[serde(flatten)]
142    key: Key,
143}
144
145impl KeyConfig {
146    /// Returns the password in case any is provided.
147    ///
148    /// If `password_file` was given, the password is read from that file.
149    async fn password(&self) -> anyhow::Result<Option<Cow<'_, [u8]>>> {
150        Ok(match &self.password {
151            Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read(path).await?)),
152            Some(Password::Value(password)) => Some(Cow::Borrowed(password.as_bytes())),
153            None => None,
154        })
155    }
156
157    /// Returns the key.
158    ///
159    /// If `key_file` was given, the key is read from that file.
160    async fn key(&self) -> anyhow::Result<Cow<'_, [u8]>> {
161        Ok(match &self.key {
162            Key::File(path) => Cow::Owned(tokio::fs::read(path).await?),
163            Key::Value(key) => Cow::Borrowed(key.as_bytes()),
164        })
165    }
166
167    /// Returns the JSON Web Key derived from this key config.
168    ///
169    /// Password and/or key are read from file if they’re given as path.
170    async fn json_web_key(&self) -> anyhow::Result<JsonWebKey<mas_keystore::PrivateKey>> {
171        let (key, password) = try_join(self.key(), self.password()).await?;
172
173        let private_key = match password {
174            Some(password) => PrivateKey::load_encrypted(&key, password)?,
175            None => PrivateKey::load(&key)?,
176        };
177
178        let kid = match self.kid.clone() {
179            Some(kid) => kid,
180            None => private_key.thumbprint_sha256_base64(),
181        };
182
183        Ok(JsonWebKey::new(private_key)
184            .with_kid(kid)
185            .with_use(mas_iana::jose::JsonWebKeyUse::Sig))
186    }
187}
188
189/// Encryption config option.
190#[derive(Debug, Clone)]
191pub enum Encryption {
192    File(Utf8PathBuf),
193    Value([u8; 32]),
194}
195
196/// Encryption fields as serialized in JSON.
197#[serde_as]
198#[derive(JsonSchema, Serialize, Deserialize, Debug, Clone)]
199struct EncryptionRaw {
200    /// File containing the encryption key for secure cookies.
201    #[schemars(with = "Option<String>")]
202    #[serde(skip_serializing_if = "Option::is_none")]
203    encryption_file: Option<Utf8PathBuf>,
204
205    /// Encryption key for secure cookies.
206    #[schemars(
207        with = "Option<String>",
208        regex(pattern = r"[0-9a-fA-F]{64}"),
209        example = &"0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
210    )]
211    #[serde_as(as = "Option<serde_with::hex::Hex>")]
212    #[serde(skip_serializing_if = "Option::is_none")]
213    encryption: Option<[u8; 32]>,
214}
215
216impl TryFrom<EncryptionRaw> for Encryption {
217    type Error = anyhow::Error;
218
219    fn try_from(value: EncryptionRaw) -> Result<Encryption, Self::Error> {
220        match (value.encryption, value.encryption_file) {
221            (None, None) => bail!("Missing `encryption` or `encryption_file`"),
222            (None, Some(path)) => Ok(Encryption::File(path)),
223            (Some(encryption), None) => Ok(Encryption::Value(encryption)),
224            (Some(_), Some(_)) => bail!("Cannot specify both `encryption` and `encryption_file`"),
225        }
226    }
227}
228
229impl From<Encryption> for EncryptionRaw {
230    fn from(value: Encryption) -> Self {
231        match value {
232            Encryption::File(path) => EncryptionRaw {
233                encryption_file: Some(path),
234                encryption: None,
235            },
236            Encryption::Value(encryption) => EncryptionRaw {
237                encryption_file: None,
238                encryption: Some(encryption),
239            },
240        }
241    }
242}
243
244/// Reads all keys from the given directory.
245async fn key_configs_from_path(path: &Utf8PathBuf) -> anyhow::Result<Vec<KeyConfig>> {
246    let mut result = vec![];
247    let mut read_dir = tokio::fs::read_dir(path).await?;
248    while let Some(dir_entry) = read_dir.next_entry().await? {
249        if !dir_entry.path().is_file() {
250            continue;
251        }
252        result.push(KeyConfig {
253            kid: None,
254            password: None,
255            key: Key::File(dir_entry.path().try_into()?),
256        });
257    }
258    Ok(result)
259}
260
261/// Application secrets
262#[serde_as]
263#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
264pub struct SecretsConfig {
265    /// Encryption key for secure cookies
266    #[schemars(with = "EncryptionRaw")]
267    #[serde_as(as = "serde_with::TryFromInto<EncryptionRaw>")]
268    #[serde(flatten)]
269    encryption: Encryption,
270
271    /// List of private keys to use for signing and encrypting payloads.
272    #[serde(skip_serializing_if = "Option::is_none")]
273    keys: Option<Vec<KeyConfig>>,
274
275    /// Directory of private keys to use for signing and encrypting payloads.
276    #[schemars(with = "Option<String>")]
277    #[serde(skip_serializing_if = "Option::is_none")]
278    keys_dir: Option<Utf8PathBuf>,
279}
280
281impl SecretsConfig {
282    /// Derive a signing and verifying keystore out of the config
283    ///
284    /// # Errors
285    ///
286    /// Returns an error when a key could not be imported
287    #[tracing::instrument(name = "secrets.load", skip_all)]
288    pub async fn key_store(&self) -> anyhow::Result<Keystore> {
289        let key_configs = self.key_configs().await?;
290        let web_keys = try_join_all(key_configs.iter().map(KeyConfig::json_web_key)).await?;
291
292        Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
293    }
294
295    /// Derive an [`Encrypter`] out of the config
296    ///
297    /// # Errors
298    ///
299    /// Returns an error when the Encryptor can not be created.
300    pub async fn encrypter(&self) -> anyhow::Result<Encrypter> {
301        Ok(Encrypter::new(&self.encryption().await?))
302    }
303
304    /// Returns the encryption secret.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error when the encryption secret could not be read from file.
309    pub async fn encryption(&self) -> anyhow::Result<[u8; 32]> {
310        // Read the encryption secret either embedded in the config file or on disk
311        match self.encryption {
312            Encryption::Value(encryption) => Ok(encryption),
313            Encryption::File(ref path) => {
314                let mut bytes = [0; 32];
315                let content = tokio::fs::read(path).await?;
316                hex::decode_to_slice(content, &mut bytes).context(
317                    "Content of `encryption_file` must contain hex characters \
318                    encoding exactly 32 bytes",
319                )?;
320                Ok(bytes)
321            }
322        }
323    }
324
325    /// Returns a combined list of key configs given inline and from files.
326    ///
327    /// If `keys_dir` was given, the keys are read from file.
328    async fn key_configs(&self) -> anyhow::Result<Vec<KeyConfig>> {
329        let mut key_configs = match &self.keys_dir {
330            Some(keys_dir) => key_configs_from_path(keys_dir).await?,
331            None => vec![],
332        };
333
334        let inline_key_configs = self.keys.as_deref().unwrap_or_default();
335        key_configs.extend(inline_key_configs.iter().cloned());
336
337        Ok(key_configs)
338    }
339}
340
341impl ConfigurationSection for SecretsConfig {
342    const PATH: Option<&'static str> = Some("secrets");
343}
344
345impl SecretsConfig {
346    #[expect(clippy::similar_names, reason = "Key type names are very similar")]
347    #[tracing::instrument(skip_all)]
348    pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
349    where
350        R: Rng + Send,
351    {
352        info!("Generating keys...");
353
354        let span = tracing::info_span!("rsa");
355        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
356        let rsa_key = task::spawn_blocking(move || {
357            let _entered = span.enter();
358            let ret = PrivateKey::generate_rsa(key_rng).unwrap();
359            info!("Done generating RSA key");
360            ret
361        })
362        .await
363        .context("could not join blocking task")?;
364        let rsa_key = KeyConfig {
365            kid: None,
366            password: None,
367            key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
368        };
369
370        let span = tracing::info_span!("ec_p256");
371        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
372        let ec_p256_key = task::spawn_blocking(move || {
373            let _entered = span.enter();
374            let ret = PrivateKey::generate_ec_p256(key_rng);
375            info!("Done generating EC P-256 key");
376            ret
377        })
378        .await
379        .context("could not join blocking task")?;
380        let ec_p256_key = KeyConfig {
381            kid: None,
382            password: None,
383            key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
384        };
385
386        let span = tracing::info_span!("ec_p384");
387        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
388        let ec_p384_key = task::spawn_blocking(move || {
389            let _entered = span.enter();
390            let ret = PrivateKey::generate_ec_p384(key_rng);
391            info!("Done generating EC P-384 key");
392            ret
393        })
394        .await
395        .context("could not join blocking task")?;
396        let ec_p384_key = KeyConfig {
397            kid: None,
398            password: None,
399            key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
400        };
401
402        let span = tracing::info_span!("ec_k256");
403        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
404        let ec_k256_key = task::spawn_blocking(move || {
405            let _entered = span.enter();
406            let ret = PrivateKey::generate_ec_k256(key_rng);
407            info!("Done generating EC secp256k1 key");
408            ret
409        })
410        .await
411        .context("could not join blocking task")?;
412        let ec_k256_key = KeyConfig {
413            kid: None,
414            password: None,
415            key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
416        };
417
418        Ok(Self {
419            encryption: Encryption::Value(Standard.sample(&mut rng)),
420            keys: Some(vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key]),
421            keys_dir: None,
422        })
423    }
424
425    pub(crate) fn test() -> Self {
426        let rsa_key = KeyConfig {
427            kid: None,
428            password: None,
429            key: Key::Value(
430                indoc::indoc! {r"
431                  -----BEGIN PRIVATE KEY-----
432                  MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
433                  QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
434                  scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
435                  3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
436                  vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
437                  N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
438                  tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
439                  Gh7BNzCeN+D6
440                  -----END PRIVATE KEY-----
441                "}
442                .to_owned(),
443            ),
444        };
445        let ecdsa_key = KeyConfig {
446            kid: None,
447            password: None,
448            key: Key::Value(
449                indoc::indoc! {r"
450                  -----BEGIN PRIVATE KEY-----
451                  MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
452                  NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
453                  OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
454                  -----END PRIVATE KEY-----
455                "}
456                .to_owned(),
457            ),
458        };
459
460        Self {
461            encryption: Encryption::Value([0xEA; 32]),
462            keys: Some(vec![rsa_key, ecdsa_key]),
463            keys_dir: None,
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    // The closures passed to `Jail::expect_with` return `figment::Error`, which is
471    // large, and we can't change figment's API.
472    #![expect(clippy::result_large_err)]
473
474    use figment::{
475        Figment, Jail,
476        providers::{Format, Yaml},
477    };
478    use mas_jose::constraints::Constrainable;
479    use tokio::{runtime::Handle, task};
480
481    use super::*;
482
483    #[tokio::test]
484    async fn load_config() {
485        task::spawn_blocking(|| {
486            Jail::expect_with(|jail| {
487                jail.create_file(
488                    "config.yaml",
489                    indoc::indoc! {r"
490                        secrets:
491                          encryption_file: encryption
492                          keys_dir: keys
493                    "},
494                )?;
495                jail.create_file(
496                    "encryption",
497                    "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff",
498                )?;
499                jail.create_dir("keys")?;
500                jail.create_file(
501                    "keys/key1",
502                    indoc::indoc! {r"
503                        -----BEGIN RSA PRIVATE KEY-----
504                        MIIJKQIBAAKCAgEA6oR6LXzJOziUxcRryonLTM5Xkfr9cYPCKvnwsWoAHfd2MC6Q
505                        OCAWSQnNcNz5RTeQUcLEaA8sxQi64zpCwO9iH8y8COCaO8u9qGkOOuJwWnmPfeLs
506                        cEwALEp0LZ67eSUPsMaz533bs4C8p+2UPMd+v7Td8TkkYoqgUrfYuT0bDTMYVsSe
507                        wcNB5qsI7hDLf1t5FX6KU79/Asn1K3UYHTdN83mghOlM4zh1l1CJdtgaE1jAg4Ml
508                        1X8yG+cT+Ks8gCSGQfIAlVFV4fvvzmpokNKfwAI/b3LS2/ft4ZrK+RCTsWsjUu38
509                        Zr8jbQMtDznzBHMw1LoaHpwRNjbJZ7uA6x5ikbwz5NAlfCITTta6xYn8qvaBfiYJ
510                        YyUFl0kIHm9Kh9V9p54WPMCFCcQx12deovKV82S6zxTeMflDdosJDB/uG9dT2qPt
511                        wkpTD6xAOx5h59IhfiY0j4ScTl725GygVzyK378soP3LQ/vBixQLpheALViotodH
512                        fJknsrelaISNkrnapZL3QE5C1SUoaUtMG9ovRz5HDpMx5ooElEklq7shFWDhZXbp
513                        2ndU5RPRCZO3Szop/Xhn2mNWQoEontFh79WIf+wS8TkJIRXhjtYBt3+s96z0iqSg
514                        gDmE8BcP4lP1+TAUY1d7+QEhGCsTJa9TYtfDtNNfuYI9e3mq6LEpHYKWOvECAwEA
515                        AQKCAgAlF60HaCGf50lzT6eePQCAdnEtWrMeyDCRgZTLStvCjEhk7d3LssTeP9mp
516                        oe8fPomUv6c3BOds2/5LQFockABHd/y/CV9RA973NclAEQlPlhiBrb793Vd4VJJe
517                        6331dveDW0+ggVdFjfVzjhqQfnE9ZcsQ2JvjpiTI0Iv2cy7F01tke0GCSMgx8W1p
518                        J2jjDOxwNOKGGoIT8S4roHVJnFy3nM4sbNtyDj+zHimP4uBE8m2zSgQAP60E8sia
519                        3+Ki1flnkXJRgQWCHR9cg5dkXfFRz56JmcdgxAHGWX2vD9XRuFi5nitPc6iTw8PV
520                        u7GvS3+MC0oO+1pRkTAhOGv3RDK3Uqmy2zrMUuWkEsz6TVId6gPl7+biRJcP+aER
521                        plJkeC9J9nSizbQPwErGByzoHGLjADgBs9hwqYkPcN38b6jR5S/VDQ+RncCyI87h
522                        s/0pIs/fNlfw4LtpBrolP6g++vo6KUufmE3kRNN9dN4lNOoKjUGkcmX6MGnwxiw6
523                        NN/uEqf9+CKQele1XeUhRPNJc9Gv+3Ly5y/wEi6FjfVQmCK4hNrl3tvuZw+qkGbq
524                        Au9Jhk7wV81An7fbhBRIXrwOY9AbOKNqUfY+wpKi5vyJFS1yzkFaYSTKTBspkuHW
525                        pWbohO+KreREwaR5HOMK8tQMTLEAeE3taXGsQMJSJ15lRrLc7QKCAQEA68TV/R8O
526                        C4p+vnGJyhcfDJt6+KBKWlroBy75BG7Dg7/rUXaj+MXcqHi+whRNXMqZchSwzUfS
527                        B2WK/HrOBye8JLKDeA3B5TumJaF19vV7EY/nBF2QdRmI1r33Cp+RWUvAcjKa/v2u
528                        KksV3btnJKXCu/stdAyTK7nU0on4qBzm5WZxuIJv6VMHLDNPFdCk+4gM8LuJ3ITU
529                        l7XuZd4gXccPNj0VTeOYiMjIwxtNmE9RpCkTLm92Z7MI+htciGk1xvV0N4m1BXwA
530                        7qhl1nBgVuJyux4dEYFIeQNhLpHozkEz913QK2gDAHL9pAeiUYJntq4p8HNvfHiQ
531                        vE3wTzil3aUFnwKCAQEA/qQm1Nx5By6an5UunrOvltbTMjsZSDnWspSQbX//j6mL
532                        2atQLe3y/Nr7E5SGZ1kFD9tgAHTuTGVqjvTqp5dBPw4uo146K2RJwuvaYUzNK26c
533                        VoGfMfsI+/bfMfjFnEmGRARZdMr8cvhU+2m04hglsSnNGxsvvPdsiIbRaVDx+JvN
534                        C5C281WlN0WeVd7zNTZkdyUARNXfCxBHQPuYkP5Mz2roZeYlJMWU04i8Cx0/SEuu
535                        bhZQDaNTccSdPDFYcyDDlpqp+mN+U7m+yUPOkVpaxQiSYJZ+NOQsNcAVYfjzyY0E
536                        /VP3s2GddjCJs0amf9SeW0LiMAHPgTp8vbMSRPVVbwKCAQEAmZsSd+llsys2TEmY
537                        pivONN6PjbCRALE9foCiCLtJcmr1m4uaZRg0HScd0UB87rmoo2TLk9L5CYyksr4n
538                        wQ2oTJhpgywjaYAlTVsWiiGBXv3MW1HCLijGuHHno+o2PmFWLpC93ufUMwXcZywT
539                        lRLR/rs07+jJcbGO8OSnNpAt9sN5z+Zblz5a6/c5zVK0SpRnKehld2CrSXRkr8W6
540                        fJ6WUJYXbTmdRXDbLBJ7yYHUBQolzxkboZBJhvmQnec9/DQq1YxIfhw+Vz8rqjxo
541                        5/J9IWALPD5owz7qb/bsIITmoIFkgQMxAXfpvJaksEov3Bs4g8oRlpzOX4C/0j1s
542                        Ay3irQKCAQEAwRJ/qufcEFkCvjsj1QsS+MC785shyUSpiE/izlO91xTLx+f/7EM9
543                        +QCkXK1B1zyE/Qft24rNYDmJOQl0nkuuGfxL2mzImDv7PYMM2reb3PGKMoEnzoKz
544                        xi/h/YbNdnm9BvdxSH/cN+QYs2Pr1X5Pneu+622KnbHQphfq0fqg7Upchwdb4Faw
545                        5Z6wthVMvK0YMcppUMgEzOOz0w6xGEbowGAkA5cj1KTG+jjzs02ivNM9V5Utb5nF
546                        3D4iphAYK3rNMfTlKsejciIlCX+TMVyb9EdSjU+uM7ZJ2xtgWx+i4NA+10GCT42V
547                        EZct4TORbN0ukK2+yH2m8yoAiOks0gJemwKCAQAMGROGt8O4HfhpUdOq01J2qvQL
548                        m5oUXX8w1I95XcoAwCqb+dIan8UbCyl/79lbqNpQlHbRy3wlXzWwH9aHKsfPlCvk
549                        5dE1qrdMdQhLXwP109bRmTiScuU4zfFgHw3XgQhMFXxNp9pze197amLws0TyuBW3
550                        fupS4kM5u6HKCeBYcw2WP5ukxf8jtn29tohLBiA2A7NYtml9xTer6BBP0DTh+QUn
551                        IJL6jSpuCNxBPKIK7p6tZZ0nMBEdAWMxglYm0bmHpTSd3pgu3ltCkYtDlDcTIaF0
552                        Q4k44lxUTZQYwtKUVQXBe4ZvaT/jIEMS7K5bsAy7URv/toaTaiEh1hguwSmf
553                        -----END RSA PRIVATE KEY-----
554                    "},
555                )?;
556                jail.create_file(
557                    "keys/key2",
558                    indoc::indoc! {r"
559                        -----BEGIN EC PRIVATE KEY-----
560                        MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
561                        AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
562                        h27LAir5RqxByHvua2XsP46rSTChof78uw==
563                        -----END EC PRIVATE KEY-----
564                    "},
565                )?;
566
567                let config = Figment::new()
568                    .merge(Yaml::file("config.yaml"))
569                    .extract_inner::<SecretsConfig>("secrets")?;
570
571                Handle::current().block_on(async move {
572                    assert!(
573                        matches!(config.encryption, Encryption::File(ref p) if p == "encryption")
574                    );
575                    assert_eq!(
576                        config.encryption().await.unwrap(),
577                        [
578                            0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
579                            136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
580                            255
581                        ]
582                    );
583
584                    let mut key_config = config.key_configs().await.unwrap();
585                    key_config.sort_by_key(|a| {
586                        if let Key::File(p) = &a.key {
587                            Some(p.clone())
588                        } else {
589                            None
590                        }
591                    });
592                    let key_store = config.key_store().await.unwrap();
593
594                    assert!(key_config[0].kid.is_none());
595                    assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key1"));
596                    assert!(key_store.iter().any(|k| k.kid() == Some("xmgGCzGtQFmhEOP0YAqBt-oZyVauSVMXcf4kwcgGZLc")));
597                    assert!(key_config[1].kid.is_none());
598                    assert!(matches!(&key_config[1].key, Key::File(p) if p == "keys/key2"));
599                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
600                });
601
602                Ok(())
603            });
604        })
605        .await
606        .unwrap();
607    }
608
609    #[tokio::test]
610    async fn load_config_inline_secrets() {
611        task::spawn_blocking(|| {
612            Jail::expect_with(|jail| {
613                jail.create_file(
614                    "config.yaml",
615                    indoc::indoc! {r"
616                        secrets:
617                          encryption: >-
618                            0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
619                          keys:
620                            - kid: lekid0
621                              key: |
622                                -----BEGIN EC PRIVATE KEY-----
623                                MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
624                                AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
625                                fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
626                                -----END EC PRIVATE KEY-----
627                            - key: |
628                                -----BEGIN EC PRIVATE KEY-----
629                                MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
630                                AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
631                                h27LAir5RqxByHvua2XsP46rSTChof78uw==
632                                -----END EC PRIVATE KEY-----
633                    "},
634                )?;
635
636                let config = Figment::new()
637                    .merge(Yaml::file("config.yaml"))
638                    .extract_inner::<SecretsConfig>("secrets")?;
639
640                Handle::current().block_on(async move {
641                    assert_eq!(
642                        config.encryption().await.unwrap(),
643                        [
644                            0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
645                            136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
646                            255
647                        ]
648                    );
649
650                    let key_store = config.key_store().await.unwrap();
651                    assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
652                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
653                });
654
655                Ok(())
656            });
657        })
658        .await
659        .unwrap();
660    }
661
662    #[tokio::test]
663    async fn load_config_mixed_key_sources() {
664        task::spawn_blocking(|| {
665            Jail::expect_with(|jail| {
666                jail.create_file(
667                    "config.yaml",
668                    indoc::indoc! {r"
669                        secrets:
670                          encryption_file: encryption
671                          keys_dir: keys
672                          keys:
673                            - kid: lekid0
674                              key: |
675                                -----BEGIN EC PRIVATE KEY-----
676                                MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
677                                AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
678                                fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
679                                -----END EC PRIVATE KEY-----
680                    "},
681                )?;
682                jail.create_dir("keys")?;
683                jail.create_file(
684                    "keys/key_from_file",
685                    indoc::indoc! {r"
686                        -----BEGIN EC PRIVATE KEY-----
687                        MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
688                        AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
689                        h27LAir5RqxByHvua2XsP46rSTChof78uw==
690                        -----END EC PRIVATE KEY-----
691                    "},
692                )?;
693
694                let config = Figment::new()
695                    .merge(Yaml::file("config.yaml"))
696                    .extract_inner::<SecretsConfig>("secrets")?;
697
698                Handle::current().block_on(async move {
699                    let key_config = config.key_configs().await.unwrap();
700                    let key_store = config.key_store().await.unwrap();
701
702                    assert!(key_config[0].kid.is_none());
703                    assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key_from_file"));
704                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
705                    assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
706                });
707
708                Ok(())
709            });
710        })
711        .await
712        .unwrap();
713    }
714}