1use 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#[derive(Clone, Debug)]
28pub enum Password {
29 File(Utf8PathBuf),
30 Value(String),
31}
32
33#[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#[derive(Clone, Debug)]
80pub enum Key {
81 File(Utf8PathBuf),
82 Value(String),
83}
84
85#[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#[serde_as]
125#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
126pub struct KeyConfig {
127 #[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 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 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 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#[derive(Debug, Clone)]
190pub enum Encryption {
191 File(Utf8PathBuf),
192 Value([u8; 32]),
193}
194
195#[serde_as]
197#[derive(JsonSchema, Serialize, Deserialize, Debug, Clone)]
198struct EncryptionRaw {
199 #[schemars(with = "Option<String>")]
201 #[serde(skip_serializing_if = "Option::is_none")]
202 encryption_file: Option<Utf8PathBuf>,
203
204 #[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#[serde_as]
245#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
246pub struct SecretsConfig {
247 #[schemars(with = "EncryptionRaw")]
249 #[serde_as(as = "serde_with::TryFromInto<EncryptionRaw>")]
250 #[serde(flatten)]
251 encryption: Encryption,
252
253 #[serde(default)]
255 keys: Vec<KeyConfig>,
256}
257
258impl SecretsConfig {
259 #[tracing::instrument(name = "secrets.load", skip_all)]
265 pub async fn key_store(&self) -> anyhow::Result<Keystore> {
266 let web_keys = try_join_all(self.keys.iter().map(KeyConfig::json_web_key)).await?;
267
268 Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
269 }
270
271 pub async fn encrypter(&self) -> anyhow::Result<Encrypter> {
277 Ok(Encrypter::new(&self.encryption().await?))
278 }
279
280 pub async fn encryption(&self) -> anyhow::Result<[u8; 32]> {
286 match self.encryption {
288 Encryption::Value(encryption) => Ok(encryption),
289 Encryption::File(ref path) => {
290 let mut bytes = [0; 32];
291 let content = tokio::fs::read(path).await?;
292 hex::decode_to_slice(content, &mut bytes).context(
293 "Content of `encryption_file` must contain hex characters \
294 encoding exactly 32 bytes",
295 )?;
296 Ok(bytes)
297 }
298 }
299 }
300}
301
302impl ConfigurationSection for SecretsConfig {
303 const PATH: Option<&'static str> = Some("secrets");
304}
305
306impl SecretsConfig {
307 #[expect(clippy::similar_names, reason = "Key type names are very similar")]
308 #[tracing::instrument(skip_all)]
309 pub(crate) async fn generate<R>(mut rng: R) -> anyhow::Result<Self>
310 where
311 R: Rng + Send,
312 {
313 info!("Generating keys...");
314
315 let span = tracing::info_span!("rsa");
316 let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
317 let rsa_key = task::spawn_blocking(move || {
318 let _entered = span.enter();
319 let ret = PrivateKey::generate_rsa(key_rng).unwrap();
320 info!("Done generating RSA key");
321 ret
322 })
323 .await
324 .context("could not join blocking task")?;
325 let rsa_key = KeyConfig {
326 kid: None,
327 password: None,
328 key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
329 };
330
331 let span = tracing::info_span!("ec_p256");
332 let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
333 let ec_p256_key = task::spawn_blocking(move || {
334 let _entered = span.enter();
335 let ret = PrivateKey::generate_ec_p256(key_rng);
336 info!("Done generating EC P-256 key");
337 ret
338 })
339 .await
340 .context("could not join blocking task")?;
341 let ec_p256_key = KeyConfig {
342 kid: None,
343 password: None,
344 key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
345 };
346
347 let span = tracing::info_span!("ec_p384");
348 let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
349 let ec_p384_key = task::spawn_blocking(move || {
350 let _entered = span.enter();
351 let ret = PrivateKey::generate_ec_p384(key_rng);
352 info!("Done generating EC P-384 key");
353 ret
354 })
355 .await
356 .context("could not join blocking task")?;
357 let ec_p384_key = KeyConfig {
358 kid: None,
359 password: None,
360 key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
361 };
362
363 let span = tracing::info_span!("ec_k256");
364 let key_rng = rand_chacha::ChaChaRng::from_rng(&mut rng)?;
365 let ec_k256_key = task::spawn_blocking(move || {
366 let _entered = span.enter();
367 let ret = PrivateKey::generate_ec_k256(key_rng);
368 info!("Done generating EC secp256k1 key");
369 ret
370 })
371 .await
372 .context("could not join blocking task")?;
373 let ec_k256_key = KeyConfig {
374 kid: None,
375 password: None,
376 key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
377 };
378
379 Ok(Self {
380 encryption: Encryption::Value(Standard.sample(&mut rng)),
381 keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key],
382 })
383 }
384
385 pub(crate) fn test() -> Self {
386 let rsa_key = KeyConfig {
387 kid: None,
388 password: None,
389 key: Key::Value(
390 indoc::indoc! {r"
391 -----BEGIN PRIVATE KEY-----
392 MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
393 QUGCG4GLJru5jzxomO9jiNr5D/oRcerhpQVc9aCpBfAAg4l4a1SmYdBzWqX0X5pU
394 scgTtQIDAQABAkEArNIMlrxUK4bSklkCcXtXdtdKE9vuWfGyOw0GyAB69fkEUBxh
395 3j65u+u3ZmW+bpMWHgp1FtdobE9nGwb2VBTWAQIhAOyU1jiUEkrwKK004+6b5QRE
396 vC9UI2vDWy5vioMNx5Y1AiEA2wGAJ6ETF8FF2Vd+kZlkKK7J0em9cl0gbJDsWIEw
397 N4ECIEyWYkMurD1WQdTQqnk0Po+DMOihdFYOiBYgRdbnPxWBAiEAmtd0xJAd7622
398 tPQniMnrBtiN2NxqFXHCev/8Gpc8gAECIBcaPcF59qVeRmYrfqzKBxFm7LmTwlAl
399 Gh7BNzCeN+D6
400 -----END PRIVATE KEY-----
401 "}
402 .to_owned(),
403 ),
404 };
405 let ecdsa_key = KeyConfig {
406 kid: None,
407 password: None,
408 key: Key::Value(
409 indoc::indoc! {r"
410 -----BEGIN PRIVATE KEY-----
411 MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
412 NaiDiepgUJ2GI5eq2V8D8nahRANCAARMK9aKUd/H28qaU+0qvS6bSJItzAge1VHn
413 OhBAAUVci1RpmUA+KdCL5sw9nadAEiONeiGr+28RYHZmlB9qXnjC
414 -----END PRIVATE KEY-----
415 "}
416 .to_owned(),
417 ),
418 };
419
420 Self {
421 encryption: Encryption::Value([0xEA; 32]),
422 keys: vec![rsa_key, ecdsa_key],
423 }
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use figment::{
430 Figment, Jail,
431 providers::{Format, Yaml},
432 };
433 use mas_jose::constraints::Constrainable;
434 use tokio::{runtime::Handle, task};
435
436 use super::*;
437
438 #[tokio::test]
439 async fn load_config_inline_secrets() {
440 task::spawn_blocking(|| {
441 Jail::expect_with(|jail| {
442 jail.create_file(
443 "config.yaml",
444 indoc::indoc! {r"
445 secrets:
446 encryption: >-
447 0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff
448 keys:
449 - kid: lekid0
450 key: |
451 -----BEGIN EC PRIVATE KEY-----
452 MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49
453 AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl
454 fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw==
455 -----END EC PRIVATE KEY-----
456 - key: |
457 -----BEGIN EC PRIVATE KEY-----
458 MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
459 AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
460 h27LAir5RqxByHvua2XsP46rSTChof78uw==
461 -----END EC PRIVATE KEY-----
462 "},
463 )?;
464
465 let config = Figment::new()
466 .merge(Yaml::file("config.yaml"))
467 .extract_inner::<SecretsConfig>("secrets")?;
468
469 Handle::current().block_on(async move {
470 assert_eq!(
471 config.encryption().await.unwrap(),
472 [
473 0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
474 136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
475 255
476 ]
477 );
478
479 let key_store = config.key_store().await.unwrap();
480 assert!(key_store.iter().any(|k| k.kid() == Some("lekid0")));
481 assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
482 });
483
484 Ok(())
485 });
486 })
487 .await
488 .unwrap();
489 }
490}