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/// Reads all keys from the given directory.
244async fn key_configs_from_path(path: &Utf8PathBuf) -> anyhow::Result<Vec<KeyConfig>> {
245    let mut result = vec![];
246    let mut read_dir = tokio::fs::read_dir(path).await?;
247    while let Some(dir_entry) = read_dir.next_entry().await? {
248        if !dir_entry.path().is_file() {
249            continue;
250        }
251        result.push(KeyConfig {
252            kid: None,
253            password: None,
254            key: Key::File(dir_entry.path().try_into()?),
255        });
256    }
257    Ok(result)
258}
259
260/// Application secrets
261#[serde_as]
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
263pub struct SecretsConfig {
264    /// Encryption key for secure cookies
265    #[schemars(with = "EncryptionRaw")]
266    #[serde_as(as = "serde_with::TryFromInto<EncryptionRaw>")]
267    #[serde(flatten)]
268    encryption: Encryption,
269
270    /// List of private keys to use for signing and encrypting payloads.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    keys: Option<Vec<KeyConfig>>,
273
274    /// Directory of private keys to use for signing and encrypting payloads.
275    #[schemars(with = "Option<String>")]
276    #[serde(skip_serializing_if = "Option::is_none")]
277    keys_dir: Option<Utf8PathBuf>,
278}
279
280impl SecretsConfig {
281    /// Derive a signing and verifying keystore out of the config
282    ///
283    /// # Errors
284    ///
285    /// Returns an error when a key could not be imported
286    #[tracing::instrument(name = "secrets.load", skip_all)]
287    pub async fn key_store(&self) -> anyhow::Result<Keystore> {
288        let key_configs = self.key_configs().await?;
289        let web_keys = try_join_all(key_configs.iter().map(KeyConfig::json_web_key)).await?;
290
291        Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
292    }
293
294    /// Derive an [`Encrypter`] out of the config
295    ///
296    /// # Errors
297    ///
298    /// Returns an error when the Encryptor can not be created.
299    pub async fn encrypter(&self) -> anyhow::Result<Encrypter> {
300        Ok(Encrypter::new(&self.encryption().await?))
301    }
302
303    /// Returns the encryption secret.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error when the encryption secret could not be read from file.
308    pub async fn encryption(&self) -> anyhow::Result<[u8; 32]> {
309        // Read the encryption secret either embedded in the config file or on disk
310        match self.encryption {
311            Encryption::Value(encryption) => Ok(encryption),
312            Encryption::File(ref path) => {
313                let mut bytes = [0; 32];
314                let content = tokio::fs::read(path).await?;
315                hex::decode_to_slice(content, &mut bytes).context(
316                    "Content of `encryption_file` must contain hex characters \
317                    encoding exactly 32 bytes",
318                )?;
319                Ok(bytes)
320            }
321        }
322    }
323
324    /// Returns a combined list of key configs given inline and from files.
325    ///
326    /// If `keys_dir` was given, the keys are read from file.
327    async fn key_configs(&self) -> anyhow::Result<Vec<KeyConfig>> {
328        let mut key_configs = match &self.keys_dir {
329            Some(keys_dir) => key_configs_from_path(keys_dir).await?,
330            None => vec![],
331        };
332
333        let inline_key_configs = self.keys.as_deref().unwrap_or_default();
334        key_configs.extend(inline_key_configs.iter().cloned());
335
336        Ok(key_configs)
337    }
338}
339
340impl ConfigurationSection for SecretsConfig {
341    const PATH: Option<&'static str> = Some("secrets");
342}
343
344impl SecretsConfig {
345    #[expect(clippy::similar_names, reason = "Key type names are very similar")]
346    #[tracing::instrument(skip_all)]
347    pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
348    where
349        R: Rng + Send,
350    {
351        info!("Generating keys...");
352
353        let span = tracing::info_span!("rsa");
354        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
355        let rsa_key = task::spawn_blocking(move || {
356            let _entered = span.enter();
357            let ret = PrivateKey::generate_rsa(key_rng).unwrap();
358            info!("Done generating RSA key");
359            ret
360        })
361        .await
362        .context("could not join blocking task")?;
363        let rsa_key = KeyConfig {
364            kid: None,
365            password: None,
366            key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
367        };
368
369        let span = tracing::info_span!("ec_p256");
370        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
371        let ec_p256_key = task::spawn_blocking(move || {
372            let _entered = span.enter();
373            let ret = PrivateKey::generate_ec_p256(key_rng);
374            info!("Done generating EC P-256 key");
375            ret
376        })
377        .await
378        .context("could not join blocking task")?;
379        let ec_p256_key = KeyConfig {
380            kid: None,
381            password: None,
382            key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
383        };
384
385        let span = tracing::info_span!("ec_p384");
386        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
387        let ec_p384_key = task::spawn_blocking(move || {
388            let _entered = span.enter();
389            let ret = PrivateKey::generate_ec_p384(key_rng);
390            info!("Done generating EC P-384 key");
391            ret
392        })
393        .await
394        .context("could not join blocking task")?;
395        let ec_p384_key = KeyConfig {
396            kid: None,
397            password: None,
398            key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
399        };
400
401        let span = tracing::info_span!("ec_k256");
402        let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
403        let ec_k256_key = task::spawn_blocking(move || {
404            let _entered = span.enter();
405            let ret = PrivateKey::generate_ec_k256(key_rng);
406            info!("Done generating EC secp256k1 key");
407            ret
408        })
409        .await
410        .context("could not join blocking task")?;
411        let ec_k256_key = KeyConfig {
412            kid: None,
413            password: None,
414            key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
415        };
416
417        Ok(Self {
418            encryption: Encryption::Value(Standard.sample(&mut rng)),
419            keys: Some(vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key]),
420            keys_dir: None,
421        })
422    }
423
424    pub(crate) fn test() -> Self {
425        let rsa_key = KeyConfig {
426            kid: None,
427            password: None,
428            key: Key::Value(
429                indoc::indoc! {r"
430                  -----BEGIN PRIVATE KEY-----
431                  MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
432                  QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
433                  scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
434                  3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
435                  vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
436                  N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
437                  tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
438                  Gh7BNzCeN+D6
439                  -----END PRIVATE KEY-----
440                "}
441                .to_owned(),
442            ),
443        };
444        let ecdsa_key = KeyConfig {
445            kid: None,
446            password: None,
447            key: Key::Value(
448                indoc::indoc! {r"
449                  -----BEGIN PRIVATE KEY-----
450                  MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
451                  NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
452                  OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
453                  -----END PRIVATE KEY-----
454                "}
455                .to_owned(),
456            ),
457        };
458
459        Self {
460            encryption: Encryption::Value([0xEA; 32]),
461            keys: Some(vec![rsa_key, ecdsa_key]),
462            keys_dir: None,
463        }
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use figment::{
470        Figment, Jail,
471        providers::{Format, Yaml},
472    };
473    use mas_jose::constraints::Constrainable;
474    use tokio::{runtime::Handle, task};
475
476    use super::*;
477
478    #[tokio::test]
479    async fn load_config() {
480        task::spawn_blocking(|| {
481            Jail::expect_with(|jail| {
482                jail.create_file(
483                    "config.yaml",
484                    indoc::indoc! {r"
485                        secrets:
486                          encryption_file: encryption
487                          keys_dir: keys
488                    "},
489                )?;
490                jail.create_file(
491                    "encryption",
492                    "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff",
493                )?;
494                jail.create_dir("keys")?;
495                jail.create_file(
496                    "keys/key1",
497                    indoc::indoc! {r"
498                        -----BEGIN RSA PRIVATE KEY-----
499                        MIIJKQIBAAKCAgEA6oR6LXzJOziUxcRryonLTM5Xkfr9cYPCKvnwsWoAHfd2MC6Q
500                        OCAWSQnNcNz5RTeQUcLEaA8sxQi64zpCwO9iH8y8COCaO8u9qGkOOuJwWnmPfeLs
501                        cEwALEp0LZ67eSUPsMaz533bs4C8p+2UPMd+v7Td8TkkYoqgUrfYuT0bDTMYVsSe
502                        wcNB5qsI7hDLf1t5FX6KU79/Asn1K3UYHTdN83mghOlM4zh1l1CJdtgaE1jAg4Ml
503                        1X8yG+cT+Ks8gCSGQfIAlVFV4fvvzmpokNKfwAI/b3LS2/ft4ZrK+RCTsWsjUu38
504                        Zr8jbQMtDznzBHMw1LoaHpwRNjbJZ7uA6x5ikbwz5NAlfCITTta6xYn8qvaBfiYJ
505                        YyUFl0kIHm9Kh9V9p54WPMCFCcQx12deovKV82S6zxTeMflDdosJDB/uG9dT2qPt
506                        wkpTD6xAOx5h59IhfiY0j4ScTl725GygVzyK378soP3LQ/vBixQLpheALViotodH
507                        fJknsrelaISNkrnapZL3QE5C1SUoaUtMG9ovRz5HDpMx5ooElEklq7shFWDhZXbp
508                        2ndU5RPRCZO3Szop/Xhn2mNWQoEontFh79WIf+wS8TkJIRXhjtYBt3+s96z0iqSg
509                        gDmE8BcP4lP1+TAUY1d7+QEhGCsTJa9TYtfDtNNfuYI9e3mq6LEpHYKWOvECAwEA
510                        AQKCAgAlF60HaCGf50lzT6eePQCAdnEtWrMeyDCRgZTLStvCjEhk7d3LssTeP9mp
511                        oe8fPomUv6c3BOds2/5LQFockABHd/y/CV9RA973NclAEQlPlhiBrb793Vd4VJJe
512                        6331dveDW0+ggVdFjfVzjhqQfnE9ZcsQ2JvjpiTI0Iv2cy7F01tke0GCSMgx8W1p
513                        J2jjDOxwNOKGGoIT8S4roHVJnFy3nM4sbNtyDj+zHimP4uBE8m2zSgQAP60E8sia
514                        3+Ki1flnkXJRgQWCHR9cg5dkXfFRz56JmcdgxAHGWX2vD9XRuFi5nitPc6iTw8PV
515                        u7GvS3+MC0oO+1pRkTAhOGv3RDK3Uqmy2zrMUuWkEsz6TVId6gPl7+biRJcP+aER
516                        plJkeC9J9nSizbQPwErGByzoHGLjADgBs9hwqYkPcN38b6jR5S/VDQ+RncCyI87h
517                        s/0pIs/fNlfw4LtpBrolP6g++vo6KUufmE3kRNN9dN4lNOoKjUGkcmX6MGnwxiw6
518                        NN/uEqf9+CKQele1XeUhRPNJc9Gv+3Ly5y/wEi6FjfVQmCK4hNrl3tvuZw+qkGbq
519                        Au9Jhk7wV81An7fbhBRIXrwOY9AbOKNqUfY+wpKi5vyJFS1yzkFaYSTKTBspkuHW
520                        pWbohO+KreREwaR5HOMK8tQMTLEAeE3taXGsQMJSJ15lRrLc7QKCAQEA68TV/R8O
521                        C4p+vnGJyhcfDJt6+KBKWlroBy75BG7Dg7/rUXaj+MXcqHi+whRNXMqZchSwzUfS
522                        B2WK/HrOBye8JLKDeA3B5TumJaF19vV7EY/nBF2QdRmI1r33Cp+RWUvAcjKa/v2u
523                        KksV3btnJKXCu/stdAyTK7nU0on4qBzm5WZxuIJv6VMHLDNPFdCk+4gM8LuJ3ITU
524                        l7XuZd4gXccPNj0VTeOYiMjIwxtNmE9RpCkTLm92Z7MI+htciGk1xvV0N4m1BXwA
525                        7qhl1nBgVuJyux4dEYFIeQNhLpHozkEz913QK2gDAHL9pAeiUYJntq4p8HNvfHiQ
526                        vE3wTzil3aUFnwKCAQEA/qQm1Nx5By6an5UunrOvltbTMjsZSDnWspSQbX//j6mL
527                        2atQLe3y/Nr7E5SGZ1kFD9tgAHTuTGVqjvTqp5dBPw4uo146K2RJwuvaYUzNK26c
528                        VoGfMfsI+/bfMfjFnEmGRARZdMr8cvhU+2m04hglsSnNGxsvvPdsiIbRaVDx+JvN
529                        C5C281WlN0WeVd7zNTZkdyUARNXfCxBHQPuYkP5Mz2roZeYlJMWU04i8Cx0/SEuu
530                        bhZQDaNTccSdPDFYcyDDlpqp+mN+U7m+yUPOkVpaxQiSYJZ+NOQsNcAVYfjzyY0E
531                        /VP3s2GddjCJs0amf9SeW0LiMAHPgTp8vbMSRPVVbwKCAQEAmZsSd+llsys2TEmY
532                        pivONN6PjbCRALE9foCiCLtJcmr1m4uaZRg0HScd0UB87rmoo2TLk9L5CYyksr4n
533                        wQ2oTJhpgywjaYAlTVsWiiGBXv3MW1HCLijGuHHno+o2PmFWLpC93ufUMwXcZywT
534                        lRLR/rs07+jJcbGO8OSnNpAt9sN5z+Zblz5a6/c5zVK0SpRnKehld2CrSXRkr8W6
535                        fJ6WUJYXbTmdRXDbLBJ7yYHUBQolzxkboZBJhvmQnec9/DQq1YxIfhw+Vz8rqjxo
536                        5/J9IWALPD5owz7qb/bsIITmoIFkgQMxAXfpvJaksEov3Bs4g8oRlpzOX4C/0j1s
537                        Ay3irQKCAQEAwRJ/qufcEFkCvjsj1QsS+MC785shyUSpiE/izlO91xTLx+f/7EM9
538                        +QCkXK1B1zyE/Qft24rNYDmJOQl0nkuuGfxL2mzImDv7PYMM2reb3PGKMoEnzoKz
539                        xi/h/YbNdnm9BvdxSH/cN+QYs2Pr1X5Pneu+622KnbHQphfq0fqg7Upchwdb4Faw
540                        5Z6wthVMvK0YMcppUMgEzOOz0w6xGEbowGAkA5cj1KTG+jjzs02ivNM9V5Utb5nF
541                        3D4iphAYK3rNMfTlKsejciIlCX+TMVyb9EdSjU+uM7ZJ2xtgWx+i4NA+10GCT42V
542                        EZct4TORbN0ukK2+yH2m8yoAiOks0gJemwKCAQAMGROGt8O4HfhpUdOq01J2qvQL
543                        m5oUXX8w1I95XcoAwCqb+dIan8UbCyl/79lbqNpQlHbRy3wlXzWwH9aHKsfPlCvk
544                        5dE1qrdMdQhLXwP109bRmTiScuU4zfFgHw3XgQhMFXxNp9pze197amLws0TyuBW3
545                        fupS4kM5u6HKCeBYcw2WP5ukxf8jtn29tohLBiA2A7NYtml9xTer6BBP0DTh+QUn
546                        IJL6jSpuCNxBPKIK7p6tZZ0nMBEdAWMxglYm0bmHpTSd3pgu3ltCkYtDlDcTIaF0
547                        Q4k44lxUTZQYwtKUVQXBe4ZvaT/jIEMS7K5bsAy7URv/toaTaiEh1hguwSmf
548                        -----END RSA PRIVATE KEY-----
549                    "},
550                )?;
551                jail.create_file(
552                    "keys/key2",
553                    indoc::indoc! {r"
554                        -----BEGIN EC PRIVATE KEY-----
555                        MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
556                        AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
557                        h27LAir5RqxByHvua2XsP46rSTChof78uw==
558                        -----END EC PRIVATE KEY-----
559                    "},
560                )?;
561
562                let config = Figment::new()
563                    .merge(Yaml::file("config.yaml"))
564                    .extract_inner::<SecretsConfig>("secrets")?;
565
566                Handle::current().block_on(async move {
567                    assert!(
568                        matches!(config.encryption, Encryption::File(ref p) if p == "encryption")
569                    );
570                    assert_eq!(
571                        config.encryption().await.unwrap(),
572                        [
573                            0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
574                            136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
575                            255
576                        ]
577                    );
578
579                    let mut key_config = config.key_configs().await.unwrap();
580                    key_config.sort_by_key(|a| {
581                        if let Key::File(p) = &a.key {
582                            Some(p.clone())
583                        } else {
584                            None
585                        }
586                    });
587                    let key_store = config.key_store().await.unwrap();
588
589                    assert!(key_config[0].kid.is_none());
590                    assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key1"));
591                    assert!(key_store.iter().any(|k| k.kid() == Some("xmgGCzGtQFmhEOP0YAqBt-oZyVauSVMXcf4kwcgGZLc")));
592                    assert!(key_config[1].kid.is_none());
593                    assert!(matches!(&key_config[1].key, Key::File(p) if p == "keys/key2"));
594                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
595                });
596
597                Ok(())
598            });
599        })
600        .await
601        .unwrap();
602    }
603
604    #[tokio::test]
605    async fn load_config_inline_secrets() {
606        task::spawn_blocking(|| {
607            Jail::expect_with(|jail| {
608                jail.create_file(
609                    "config.yaml",
610                    indoc::indoc! {r"
611                        secrets:
612                          encryption: >-
613                            0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
614                          keys:
615                            - kid: lekid0
616                              key: |
617                                -----BEGIN EC PRIVATE KEY-----
618                                MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
619                                AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
620                                fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
621                                -----END EC PRIVATE KEY-----
622                            - key: |
623                                -----BEGIN EC PRIVATE KEY-----
624                                MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
625                                AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
626                                h27LAir5RqxByHvua2XsP46rSTChof78uw==
627                                -----END EC PRIVATE KEY-----
628                    "},
629                )?;
630
631                let config = Figment::new()
632                    .merge(Yaml::file("config.yaml"))
633                    .extract_inner::<SecretsConfig>("secrets")?;
634
635                Handle::current().block_on(async move {
636                    assert_eq!(
637                        config.encryption().await.unwrap(),
638                        [
639                            0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
640                            136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
641                            255
642                        ]
643                    );
644
645                    let key_store = config.key_store().await.unwrap();
646                    assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
647                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
648                });
649
650                Ok(())
651            });
652        })
653        .await
654        .unwrap();
655    }
656
657    #[tokio::test]
658    async fn load_config_mixed_key_sources() {
659        task::spawn_blocking(|| {
660            Jail::expect_with(|jail| {
661                jail.create_file(
662                    "config.yaml",
663                    indoc::indoc! {r"
664                        secrets:
665                          encryption_file: encryption
666                          keys_dir: keys
667                          keys:
668                            - kid: lekid0
669                              key: |
670                                -----BEGIN EC PRIVATE KEY-----
671                                MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
672                                AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
673                                fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
674                                -----END EC PRIVATE KEY-----
675                    "},
676                )?;
677                jail.create_dir("keys")?;
678                jail.create_file(
679                    "keys/key_from_file",
680                    indoc::indoc! {r"
681                        -----BEGIN EC PRIVATE KEY-----
682                        MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
683                        AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
684                        h27LAir5RqxByHvua2XsP46rSTChof78uw==
685                        -----END EC PRIVATE KEY-----
686                    "},
687                )?;
688
689                let config = Figment::new()
690                    .merge(Yaml::file("config.yaml"))
691                    .extract_inner::<SecretsConfig>("secrets")?;
692
693                Handle::current().block_on(async move {
694                    let key_config = config.key_configs().await.unwrap();
695                    let key_store = config.key_store().await.unwrap();
696
697                    assert!(key_config[0].kid.is_none());
698                    assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key_from_file"));
699                    assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
700                    assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
701                });
702
703                Ok(())
704            });
705        })
706        .await
707        .unwrap();
708    }
709}