From 2def4f561ca5c8e96d07ec95d972f8b0bcf9fb4f Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 26 May 2025 13:24:15 +0200 Subject: [PATCH 1/8] Add futures crate Signed-off-by: Kai A. Hiller --- Cargo.lock | 1 + crates/config/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 5d0ba1748..8e2e0b202 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3219,6 +3219,7 @@ dependencies = [ "camino", "chrono", "figment", + "futures", "governor", "indoc", "ipnetwork", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 7566b59c0..236b44a70 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -15,6 +15,7 @@ workspace = true tokio.workspace = true tracing.workspace = true anyhow.workspace = true +futures = "0.3.31" camino = { workspace = true, features = ["serde1"] } chrono.workspace = true From b0fcf0bf78a0a698f9c669b8ff42fb171b4117f4 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 2 Jun 2025 14:03:05 +0200 Subject: [PATCH 2/8] Add KeyConfig doc comment Signed-off-by: Kai A. Hiller --- crates/config/src/sections/secrets.rs | 1 + docs/config.schema.json | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 10df52e02..6edcfde69 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -27,6 +27,7 @@ fn example_secret() -> &'static str { "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" } +/// A single key with its key ID and optional password. #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] pub struct KeyConfig { kid: String, diff --git a/docs/config.schema.json b/docs/config.schema.json index 3bc0f407d..a58957af0 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1538,6 +1538,7 @@ } }, "KeyConfig": { + "description": "A single key with its key ID and optional password.", "type": "object", "required": [ "kid" From e92d16c35c542f4f1c4e89690909c464362a3fbe Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 26 May 2025 13:53:42 +0200 Subject: [PATCH 3/8] Refactor password options in secret config Signed-off-by: Kai A. Hiller --- crates/config/src/sections/secrets.rs | 95 +++++++++++++++++++-------- docs/config.schema.json | 8 +-- 2 files changed, 73 insertions(+), 30 deletions(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 6edcfde69..69bb51d99 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -27,17 +27,66 @@ fn example_secret() -> &'static str { "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" } +/// Password config option. +/// +/// It either holds the password value directly or references a file where the +/// password is stored. +#[derive(Clone, Debug)] +pub enum Password { + File(Utf8PathBuf), + Value(String), +} + +/// Password fields as serialized in JSON. +#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] +struct PasswordRaw { + #[schemars(with = "Option")] + password_file: Option, + password: Option, +} + +impl TryFrom for Option { + type Error = anyhow::Error; + + fn try_from(value: PasswordRaw) -> Result { + match (value.password, value.password_file) { + (None, None) => Ok(None), + (None, Some(path)) => Ok(Some(Password::File(path))), + (Some(password), None) => Ok(Some(Password::Value(password))), + (Some(_), Some(_)) => bail!("Cannot specify both `password` and `password_file`"), + } + } +} + +impl From> for PasswordRaw { + fn from(value: Option) -> Self { + match value { + Some(Password::File(path)) => PasswordRaw { + password_file: Some(path), + password: None, + }, + Some(Password::Value(password)) => PasswordRaw { + password_file: None, + password: Some(password), + }, + None => PasswordRaw { + password_file: None, + password: None, + }, + } + } +} + /// A single key with its key ID and optional password. +#[serde_as] #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] pub struct KeyConfig { kid: String, - #[serde(skip_serializing_if = "Option::is_none")] - password: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(with = "Option")] - password_file: Option, + #[schemars(with = "PasswordRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + password: Option, #[serde(skip_serializing_if = "Option::is_none")] key: Option, @@ -47,6 +96,19 @@ pub struct KeyConfig { key_file: Option, } +impl KeyConfig { + /// Returns the password in case any is provided. + /// + /// If `password_file` was given, the password is read from that file. + async fn password(&self) -> anyhow::Result>> { + Ok(match &self.password { + Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)), + Some(Password::Value(password)) => Some(Cow::Borrowed(password)), + None => None, + }) + } +} + /// Application secrets #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -75,14 +137,7 @@ impl SecretsConfig { pub async fn key_store(&self) -> anyhow::Result { let mut keys = Vec::with_capacity(self.keys.len()); for item in &self.keys { - let password = match (&item.password, &item.password_file) { - (None, None) => None, - (Some(_), Some(_)) => { - bail!("Cannot specify both `password` and `password_file`") - } - (Some(password), None) => Some(Cow::Borrowed(password)), - (None, Some(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)), - }; + let password = item.password().await?; // Read the key either embedded in the config file or on disk let key = match (&item.key, &item.key_file) { @@ -154,12 +209,6 @@ impl ConfigurationSection for SecretsConfig { "Cannot specify both `key` and `key_file`".to_owned(), )); } - - if key.password.is_some() && key.password_file.is_some() { - return annotate(figment::Error::from( - "Cannot specify both `password` and `password_file`".to_owned(), - )); - } } Ok(()) @@ -187,7 +236,6 @@ impl SecretsConfig { let rsa_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - password_file: None, key: Some(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), key_file: None, }; @@ -205,7 +253,6 @@ impl SecretsConfig { let ec_p256_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - password_file: None, key: Some(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), key_file: None, }; @@ -223,7 +270,6 @@ impl SecretsConfig { let ec_p384_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - password_file: None, key: Some(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), key_file: None, }; @@ -241,7 +287,6 @@ impl SecretsConfig { let ec_k256_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - password_file: None, key: Some(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), key_file: None, }; @@ -256,7 +301,6 @@ impl SecretsConfig { let rsa_key = KeyConfig { kid: "abcdef".to_owned(), password: None, - password_file: None, key: Some( indoc::indoc! {r" -----BEGIN PRIVATE KEY----- @@ -277,7 +321,6 @@ impl SecretsConfig { let ecdsa_key = KeyConfig { kid: "ghijkl".to_owned(), password: None, - password_file: None, key: Some( indoc::indoc! {r" -----BEGIN PRIVATE KEY----- diff --git a/docs/config.schema.json b/docs/config.schema.json index a58957af0..22c5d32be 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1547,16 +1547,16 @@ "kid": { "type": "string" }, - "password": { + "key": { "type": "string" }, - "password_file": { + "key_file": { "type": "string" }, - "key": { + "password_file": { "type": "string" }, - "key_file": { + "password": { "type": "string" } } From ec693edb728a8847edc8986386776d4e36a2fc6d Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 26 May 2025 14:05:39 +0200 Subject: [PATCH 4/8] Refactor key options in secret config Signed-off-by: Kai A. Hiller --- crates/config/src/sections/secrets.rs | 141 +++++++++++++------------- docs/config.schema.json | 8 +- 2 files changed, 76 insertions(+), 73 deletions(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 69bb51d99..8eba01a86 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -77,6 +77,52 @@ impl From> for PasswordRaw { } } +/// Key config option. +/// +/// It either holds the key value directly or references a file where the key is +/// stored. +#[derive(Clone, Debug)] +pub enum Key { + File(Utf8PathBuf), + Value(String), +} + +/// Key fields as serialized in JSON. +#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] +struct KeyRaw { + #[schemars(with = "Option")] + key_file: Option, + key: Option, +} + +impl TryFrom for Key { + type Error = anyhow::Error; + + fn try_from(value: KeyRaw) -> Result { + match (value.key, value.key_file) { + (None, None) => bail!("Missing `key` or `key_file`"), + (None, Some(path)) => Ok(Key::File(path)), + (Some(key), None) => Ok(Key::Value(key)), + (Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"), + } + } +} + +impl From for KeyRaw { + fn from(value: Key) -> Self { + match value { + Key::File(path) => KeyRaw { + key_file: Some(path), + key: None, + }, + Key::Value(key) => KeyRaw { + key_file: None, + key: Some(key), + }, + } + } +} + /// A single key with its key ID and optional password. #[serde_as] #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] @@ -88,12 +134,10 @@ pub struct KeyConfig { #[serde(flatten)] password: Option, - #[serde(skip_serializing_if = "Option::is_none")] - key: Option, - - #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(with = "Option")] - key_file: Option, + #[schemars(with = "KeyRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + key: Key, } impl KeyConfig { @@ -107,6 +151,16 @@ impl KeyConfig { None => None, }) } + + /// Returns the key. + /// + /// If `key_file` was given, the key is read from that file. + async fn key(&self) -> anyhow::Result> { + Ok(match &self.key { + Key::File(path) => Cow::Owned(tokio::fs::read_to_string(path).await?), + Key::Value(key) => Cow::Borrowed(key), + }) + } } /// Application secrets @@ -139,31 +193,13 @@ impl SecretsConfig { for item in &self.keys { let password = item.password().await?; - // Read the key either embedded in the config file or on disk - let key = match (&item.key, &item.key_file) { - (None, None) => bail!("Missing `key` or `key_file`"), - (Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"), - (Some(key), None) => { - // If the key was embedded in the config file, assume it is formatted as PEM - if let Some(password) = password { - PrivateKey::load_encrypted_pem(key, password.as_bytes())? - } else { - PrivateKey::load_pem(key)? - } - } - (None, Some(path)) => { - // When reading from disk, it might be either PEM or DER. `PrivateKey::load*` - // will try both. - let key = tokio::fs::read(path).await?; - if let Some(password) = password { - PrivateKey::load_encrypted(&key, password.as_bytes())? - } else { - PrivateKey::load(&key)? - } - } + let key = item.key().await?; + let private_key = match password { + Some(password) => PrivateKey::load_encrypted(key.as_bytes(), password.as_bytes())?, + None => PrivateKey::load(key.as_bytes())?, }; - let key = JsonWebKey::new(key) + let key = JsonWebKey::new(private_key) .with_kid(item.kid.clone()) .with_use(mas_iana::jose::JsonWebKeyUse::Sig); keys.push(key); @@ -183,34 +219,7 @@ impl SecretsConfig { impl ConfigurationSection for SecretsConfig { const PATH: Option<&'static str> = Some("secrets"); - fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> { - for (index, key) in self.keys.iter().enumerate() { - let annotate = |mut error: figment::Error| { - error.metadata = figment - .find_metadata(&format!("{root}.keys", root = Self::PATH.unwrap())) - .cloned(); - error.profile = Some(figment::Profile::Default); - error.path = vec![ - Self::PATH.unwrap().to_owned(), - "keys".to_owned(), - index.to_string(), - ]; - Err(error) - }; - - if key.key.is_none() && key.key_file.is_none() { - return annotate(figment::Error::from( - "Missing `key` or `key_file`".to_owned(), - )); - } - - if key.key.is_some() && key.key_file.is_some() { - return annotate(figment::Error::from( - "Cannot specify both `key` and `key_file`".to_owned(), - )); - } - } - + fn validate(&self, _figment: &figment::Figment) -> Result<(), figment::Error> { Ok(()) } } @@ -236,8 +245,7 @@ impl SecretsConfig { let rsa_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - key: Some(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), - key_file: None, + key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; let span = tracing::info_span!("ec_p256"); @@ -253,8 +261,7 @@ impl SecretsConfig { let ec_p256_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - key: Some(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), - key_file: None, + key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; let span = tracing::info_span!("ec_p384"); @@ -270,8 +277,7 @@ impl SecretsConfig { let ec_p384_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - key: Some(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), - key_file: None, + key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; let span = tracing::info_span!("ec_k256"); @@ -287,8 +293,7 @@ impl SecretsConfig { let ec_k256_key = KeyConfig { kid: Alphanumeric.sample_string(&mut rng, 10), password: None, - key: Some(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), - key_file: None, + key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()), }; Ok(Self { @@ -301,7 +306,7 @@ impl SecretsConfig { let rsa_key = KeyConfig { kid: "abcdef".to_owned(), password: None, - key: Some( + key: Key::Value( indoc::indoc! {r" -----BEGIN PRIVATE KEY----- MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN @@ -316,12 +321,11 @@ impl SecretsConfig { "} .to_owned(), ), - key_file: None, }; let ecdsa_key = KeyConfig { kid: "ghijkl".to_owned(), password: None, - key: Some( + key: Key::Value( indoc::indoc! {r" -----BEGIN PRIVATE KEY----- MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA @@ -331,7 +335,6 @@ impl SecretsConfig { "} .to_owned(), ), - key_file: None, }; Self { diff --git a/docs/config.schema.json b/docs/config.schema.json index 22c5d32be..22e994d96 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1547,16 +1547,16 @@ "kid": { "type": "string" }, - "key": { + "password_file": { "type": "string" }, - "key_file": { + "password": { "type": "string" }, - "password_file": { + "key_file": { "type": "string" }, - "password": { + "key": { "type": "string" } } From c67fb80b1e675d86fa66a67fec8fd5edf8c82b48 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 26 May 2025 14:17:14 +0200 Subject: [PATCH 5/8] Load keys concurrently Signed-off-by: Kai A. Hiller --- crates/config/src/sections/secrets.rs | 36 ++++++++++++++------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 8eba01a86..233118088 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -8,6 +8,7 @@ use std::borrow::Cow; use anyhow::{Context, bail}; use camino::Utf8PathBuf; +use futures::future::{try_join, try_join_all}; use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; use rand::{ @@ -161,6 +162,22 @@ impl KeyConfig { Key::Value(key) => Cow::Borrowed(key), }) } + + /// Returns the JSON Web Key derived from this key config. + /// + /// Password and/or key are read from file if they’re given as path. + async fn json_web_key(&self) -> anyhow::Result> { + let (key, password) = try_join(self.key(), self.password()).await?; + + let private_key = match password { + Some(password) => PrivateKey::load_encrypted(key.as_bytes(), password.as_bytes())?, + None => PrivateKey::load(key.as_bytes())?, + }; + + Ok(JsonWebKey::new(private_key) + .with_kid(self.kid.clone()) + .with_use(mas_iana::jose::JsonWebKeyUse::Sig)) + } } /// Application secrets @@ -189,24 +206,9 @@ impl SecretsConfig { /// Returns an error when a key could not be imported #[tracing::instrument(name = "secrets.load", skip_all)] pub async fn key_store(&self) -> anyhow::Result { - let mut keys = Vec::with_capacity(self.keys.len()); - for item in &self.keys { - let password = item.password().await?; - - let key = item.key().await?; - let private_key = match password { - Some(password) => PrivateKey::load_encrypted(key.as_bytes(), password.as_bytes())?, - None => PrivateKey::load(key.as_bytes())?, - }; - - let key = JsonWebKey::new(private_key) - .with_kid(item.kid.clone()) - .with_use(mas_iana::jose::JsonWebKeyUse::Sig); - keys.push(key); - } + let web_keys = try_join_all(self.keys.iter().map(KeyConfig::json_web_key)).await?; - let keys = JsonWebKeySet::new(keys); - Ok(Keystore::new(keys)) + Ok(Keystore::new(JsonWebKeySet::new(web_keys))) } /// Derive an [`Encrypter`] out of the config From bc13812035f5ff412e84f21abb2a0d6b30dd401a Mon Sep 17 00:00:00 2001 From: V02460 Date: Wed, 4 Jun 2025 10:24:29 +0200 Subject: [PATCH 6/8] Use futures-util dependency Co-authored-by: Quentin Gliech --- Cargo.lock | 2 +- crates/config/Cargo.toml | 2 +- crates/config/src/sections/secrets.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8e2e0b202..ba4c04bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3219,7 +3219,7 @@ dependencies = [ "camino", "chrono", "figment", - "futures", + "futures-util", "governor", "indoc", "ipnetwork", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 236b44a70..349ea0768 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -15,7 +15,7 @@ workspace = true tokio.workspace = true tracing.workspace = true anyhow.workspace = true -futures = "0.3.31" +futures-util.workspace = true camino = { workspace = true, features = ["serde1"] } chrono.workspace = true diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 233118088..abe977b81 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use anyhow::{Context, bail}; use camino::Utf8PathBuf; -use futures::future::{try_join, try_join_all}; +use futures_util::future::{try_join, try_join_all}; use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; use rand::{ From c5b1db704280e4e841307b1a53adc0ff77e92948 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 4 Jun 2025 10:27:16 +0200 Subject: [PATCH 7/8] Use default implementation of validate function --- crates/config/src/sections/secrets.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index abe977b81..afd8c3aa1 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -220,10 +220,6 @@ impl SecretsConfig { impl ConfigurationSection for SecretsConfig { const PATH: Option<&'static str> = Some("secrets"); - - fn validate(&self, _figment: &figment::Figment) -> Result<(), figment::Error> { - Ok(()) - } } impl SecretsConfig { From d8081c29b113eb160a80fbed4fbca9b5e11a0517 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 4 Jun 2025 11:52:33 +0200 Subject: [PATCH 8/8] Skip deserialization if field is None --- crates/config/src/sections/secrets.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index afd8c3aa1..eabaa0556 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -42,7 +42,9 @@ pub enum Password { #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] struct PasswordRaw { #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] password_file: Option, + #[serde(skip_serializing_if = "Option::is_none")] password: Option, } @@ -92,7 +94,9 @@ pub enum Key { #[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)] struct KeyRaw { #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] key_file: Option, + #[serde(skip_serializing_if = "Option::is_none")] key: Option, }