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