diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 003f932ec..98feb672d 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -240,6 +240,23 @@ impl From for EncryptionRaw { } } +/// Reads all keys from the given directory. +async fn key_configs_from_path(path: &Utf8PathBuf) -> anyhow::Result> { + let mut result = vec![]; + let mut read_dir = tokio::fs::read_dir(path).await?; + while let Some(dir_entry) = read_dir.next_entry().await? { + if !dir_entry.path().is_file() { + continue; + } + result.push(KeyConfig { + kid: None, + password: None, + key: Key::File(dir_entry.path().try_into()?), + }); + } + Ok(result) +} + /// Application secrets #[serde_as] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -250,9 +267,14 @@ pub struct SecretsConfig { #[serde(flatten)] encryption: Encryption, - /// List of private keys to use for signing and encrypting payloads - #[serde(default)] - keys: Vec, + /// List of private keys to use for signing and encrypting payloads. + #[serde(skip_serializing_if = "Option::is_none")] + keys: Option>, + + /// Directory of private keys to use for signing and encrypting payloads. + #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] + keys_dir: Option, } impl SecretsConfig { @@ -263,7 +285,8 @@ 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 web_keys = try_join_all(self.keys.iter().map(KeyConfig::json_web_key)).await?; + let key_configs = self.key_configs().await?; + let web_keys = try_join_all(key_configs.iter().map(KeyConfig::json_web_key)).await?; Ok(Keystore::new(JsonWebKeySet::new(web_keys))) } @@ -297,6 +320,21 @@ impl SecretsConfig { } } } + + /// Returns a combined list of key configs given inline and from files. + /// + /// If `keys_dir` was given, the keys are read from file. + async fn key_configs(&self) -> anyhow::Result> { + let mut key_configs = match &self.keys_dir { + Some(keys_dir) => key_configs_from_path(keys_dir).await?, + None => vec![], + }; + + let inline_key_configs = self.keys.as_deref().unwrap_or_default(); + key_configs.extend(inline_key_configs.iter().cloned()); + + Ok(key_configs) + } } impl ConfigurationSection for SecretsConfig { @@ -378,7 +416,8 @@ impl SecretsConfig { Ok(Self { encryption: Encryption::Value(Standard.sample(&mut rng)), - keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key], + keys: Some(vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key]), + keys_dir: None, }) } @@ -419,7 +458,8 @@ impl SecretsConfig { Self { encryption: Encryption::Value([0xEA; 32]), - keys: vec![rsa_key, ecdsa_key], + keys: Some(vec![rsa_key, ecdsa_key]), + keys_dir: None, } } } @@ -435,6 +475,132 @@ mod tests { use super::*; + #[tokio::test] + async fn load_config() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + indoc::indoc! {r" + secrets: + encryption_file: encryption + keys_dir: keys + "}, + )?; + jail.create_file( + "encryption", + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff", + )?; + jail.create_dir("keys")?; + jail.create_file( + "keys/key1", + indoc::indoc! {r" + -----BEGIN RSA PRIVATE KEY----- + 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 + -----END RSA PRIVATE KEY----- + "}, + )?; + jail.create_file( + "keys/key2", + indoc::indoc! {r" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49 + AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9 + h27LAir5RqxByHvua2XsP46rSTChof78uw== + -----END EC PRIVATE KEY----- + "}, + )?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("secrets")?; + + Handle::current().block_on(async move { + assert!( + matches!(config.encryption, Encryption::File(ref p) if p == "encryption") + ); + assert_eq!( + config.encryption().await.unwrap(), + [ + 0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136, + 136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255, + 255 + ] + ); + + let mut key_config = config.key_configs().await.unwrap(); + key_config.sort_by_key(|a| { + if let Key::File(p) = &a.key { + Some(p.clone()) + } else { + None + } + }); + let key_store = config.key_store().await.unwrap(); + + assert!(key_config[0].kid.is_none()); + assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key1")); + assert!(key_store.iter().any(|k| k.kid() == Some("xmgGCzGtQFmhEOP0YAqBt-oZyVauSVMXcf4kwcgGZLc"))); + assert!(key_config[1].kid.is_none()); + assert!(matches!(&key_config[1].key, Key::File(p) if p == "keys/key2")); + assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o"))); + }); + + Ok(()) + }); + }) + .await + .unwrap(); + } + #[tokio::test] async fn load_config_inline_secrets() { task::spawn_blocking(|| { @@ -487,4 +653,57 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn load_config_mixed_key_sources() { + task::spawn_blocking(|| { + Jail::expect_with(|jail| { + jail.create_file( + "config.yaml", + indoc::indoc! {r" + secrets: + encryption_file: encryption + keys_dir: keys + keys: + - kid: lekid0 + key: | + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIOtZfDuXZr/NC0V3sisR4Chf7RZg6a2dpZesoXMlsPeRoAoGCCqGSM49 + AwEHoUQDQgAECfpqx64lrR85MOhdMxNmIgmz8IfmM5VY9ICX9aoaArnD9FjgkBIl + fGmQWxxXDSWH6SQln9tROVZaduenJqDtDw== + -----END EC PRIVATE KEY----- + "}, + )?; + jail.create_dir("keys")?; + jail.create_file( + "keys/key_from_file", + indoc::indoc! {r" + -----BEGIN EC PRIVATE KEY----- + MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49 + AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9 + h27LAir5RqxByHvua2XsP46rSTChof78uw== + -----END EC PRIVATE KEY----- + "}, + )?; + + let config = Figment::new() + .merge(Yaml::file("config.yaml")) + .extract_inner::("secrets")?; + + Handle::current().block_on(async move { + let key_config = config.key_configs().await.unwrap(); + let key_store = config.key_store().await.unwrap(); + + assert!(key_config[0].kid.is_none()); + assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key_from_file")); + assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o"))); + assert!(key_store.iter().any(|k| k.kid() == Some("lekid0"))); + }); + + Ok(()) + }); + }) + .await + .unwrap(); + } } diff --git a/docs/config.schema.json b/docs/config.schema.json index eeb6992cc..d38ff0f2d 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1640,12 +1640,21 @@ "pattern": "[0-9a-fA-F]{64}" }, "keys": { - "description": "List of private keys to use for signing and encrypting payloads", - "type": "array", + "description": "List of private keys to use for signing and encrypting payloads.", + "type": [ + "array", + "null" + ], "items": { "$ref": "#/definitions/KeyConfig" - }, - "default": [] + } + }, + "keys_dir": { + "description": "Directory of private keys to use for signing and encrypting payloads.", + "type": [ + "string", + "null" + ] } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 6f69e4f12..f614791dd 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -222,7 +222,7 @@ The secret is not updated when the content of the file changes. > Changing the encryption secret afterwards will lead to a loss of all encrypted > information in the database. -### `secrets.keys` +### Singing Keys The service can use a number of key types for signing. The following key types are supported: @@ -232,15 +232,26 @@ The following key types are supported: - ECDSA with the P-384 (`secp384r1`) curve - ECDSA with the K-256 (`secp256k1`) curve -Each entry in the list corresponds to one signing key used by MAS. -The key can either be specified inline (with the `key` property), -or loaded from a file (with the `key_file` property). The following key formats are supported: - PKCS#1 PEM or DER-encoded RSA private key - PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not - SEC1 PEM or DER-encoded ECDSA private key +The keys can be given as a directory path via `secrets.keys_dir` +or, alternatively, as an inline configuration list via `secrets.keys`. + +#### `secrets.keys_dir` + +Path to the directory containing MAS signing key files. +Only keys that don’t require a password are supported. + +#### `secrets.keys` + +Each entry in the list corresponds to one signing key used by MAS. +The key can either be specified inline (with the `key` property), +or loaded from a file (with the `key_file` property). + A [JWK Key ID] is automatically derived from each key. To override this default, set `kid` to a custom value. The `kid` can be any case-sensitive string value as long as it is unique to this list;