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