Skip to content

Commit 6a28950

Browse files
committed
Add secrets.keys_dir config option
1 parent 38589a6 commit 6a28950

File tree

3 files changed

+237
-17
lines changed

3 files changed

+237
-17
lines changed

crates/config/src/sections/secrets.rs

Lines changed: 211 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,86 @@ impl From<Encryption> for EncryptionRaw {
244244
}
245245
}
246246

247+
/// Description of signing keys.
248+
///
249+
/// It either holds the key config values directly or references a directory
250+
/// where each file contains a key.
251+
#[derive(Debug, Clone)]
252+
pub enum Keys {
253+
Values(Vec<KeyConfig>),
254+
Directory(Utf8PathBuf),
255+
}
256+
257+
impl Keys {
258+
/// Returns a list of key configs.
259+
///
260+
/// If `keys_dir` was given, the keys are read from file.
261+
async fn key_configs(&self) -> anyhow::Result<Vec<KeyConfig>> {
262+
match self {
263+
Keys::Values(key_configs) => Ok(key_configs.clone()),
264+
Keys::Directory(path) => key_configs_from_path(path).await,
265+
}
266+
}
267+
}
268+
269+
/// Reads all keys from the given directory.
270+
async fn key_configs_from_path(path: &Utf8PathBuf) -> anyhow::Result<Vec<KeyConfig>> {
271+
let mut result = vec![];
272+
let mut read_dir = tokio::fs::read_dir(path).await?;
273+
while let Some(dir_entry) = read_dir.next_entry().await? {
274+
if !dir_entry.path().is_file() {
275+
continue;
276+
}
277+
result.push(KeyConfig {
278+
kid: None,
279+
password: None,
280+
key: Key::File(dir_entry.path().try_into()?),
281+
});
282+
}
283+
Ok(result)
284+
}
285+
286+
#[serde_as]
287+
#[derive(JsonSchema, Serialize, Deserialize, Debug, Clone)]
288+
struct KeysRaw {
289+
/// List of private keys to use for signing and encrypting payloads.
290+
#[serde(skip_serializing_if = "Option::is_none")]
291+
keys: Option<Vec<KeyConfig>>,
292+
293+
/// Directory of private keys to use for signing and encrypting payloads.
294+
#[schemars(with = "Option<String>")]
295+
#[serde(skip_serializing_if = "Option::is_none")]
296+
keys_dir: Option<Utf8PathBuf>,
297+
}
298+
299+
impl TryFrom<KeysRaw> for Keys {
300+
type Error = anyhow::Error;
301+
302+
fn try_from(value: KeysRaw) -> Result<Keys, Self::Error> {
303+
match (value.keys, value.keys_dir) {
304+
(None, None) => bail!("Missing `keys` or `keys_dir`"),
305+
(None, Some(path)) => Ok(Keys::Directory(path)),
306+
(Some(keys), None) => Ok(Keys::Values(keys)),
307+
(Some(_), Some(_)) => bail!("Cannot specify both `keys` and `keys_dir`"),
308+
}
309+
}
310+
}
311+
312+
impl From<Keys> for KeysRaw {
313+
fn from(value: Keys) -> Self {
314+
match value {
315+
Keys::Directory(path) => KeysRaw {
316+
keys_dir: Some(path),
317+
keys: None,
318+
},
319+
Keys::Values(keys) => KeysRaw {
320+
keys_dir: None,
321+
keys: Some(keys),
322+
},
323+
}
324+
}
325+
}
326+
247327
/// Application secrets
248328
#[serde_as]
249329
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
@@ -255,8 +335,10 @@ pub struct SecretsConfig {
255335
encryption: Encryption,
256336

257337
/// List of private keys to use for signing and encrypting payloads
258-
#[serde(default)]
259-
keys: Vec<KeyConfig>,
338+
#[schemars(with = "KeysRaw")]
339+
#[serde_as(as = "serde_with::TryFromInto<KeysRaw>")]
340+
#[serde(flatten)]
341+
keys: Keys,
260342
}
261343

262344
impl SecretsConfig {
@@ -267,7 +349,8 @@ impl SecretsConfig {
267349
/// Returns an error when a key could not be imported
268350
#[tracing::instrument(name = "secrets.load", skip_all)]
269351
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?;
352+
let key_configs = self.keys.key_configs().await?;
353+
let web_keys = try_join_all(key_configs.iter().map(KeyConfig::json_web_key)).await?;
271354

272355
Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
273356
}
@@ -382,7 +465,7 @@ impl SecretsConfig {
382465

383466
Ok(Self {
384467
encryption: Encryption::Value(Standard.sample(&mut rng)),
385-
keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key],
468+
keys: Keys::Values(vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key]),
386469
})
387470
}
388471

@@ -423,7 +506,7 @@ impl SecretsConfig {
423506

424507
Self {
425508
encryption: Encryption::Value([0xEA; 32]),
426-
keys: vec![rsa_key, ecdsa_key],
509+
keys: Keys::Values(vec![rsa_key, ecdsa_key]),
427510
}
428511
}
429512
}
@@ -439,6 +522,129 @@ mod tests {
439522

440523
use super::*;
441524

525+
#[tokio::test]
526+
async fn load_config() {
527+
task::spawn_blocking(|| {
528+
Jail::expect_with(|jail| {
529+
jail.create_file(
530+
"config.yaml",
531+
indoc::indoc! {r"
532+
secrets:
533+
encryption_file: encryption
534+
keys_dir: keys
535+
"},
536+
)?;
537+
jail.create_file("encryption", example_secret())?;
538+
jail.create_dir("keys")?;
539+
jail.create_file(
540+
"keys/key1",
541+
indoc::indoc! {r"
542+
-----BEGIN RSA PRIVATE KEY-----
543+
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
592+
-----END RSA PRIVATE KEY-----
593+
"},
594+
)?;
595+
jail.create_file(
596+
"keys/key2",
597+
indoc::indoc! {r"
598+
-----BEGIN EC PRIVATE KEY-----
599+
MHcCAQEEIKlZz/GnH0idVH1PnAF4HQNwRafgBaE2tmyN1wjfdOQqoAoGCCqGSM49
600+
AwEHoUQDQgAEHrgPeG+Mt8eahih1h4qaPjhl7jT25cdzBkg3dbVks6gBR2Rx4ug9
601+
h27LAir5RqxByHvua2XsP46rSTChof78uw==
602+
-----END EC PRIVATE KEY-----
603+
"},
604+
)?;
605+
606+
let config = Figment::new()
607+
.merge(Yaml::file("config.yaml"))
608+
.extract_inner::<SecretsConfig>("secrets")?;
609+
610+
Handle::current().block_on(async move {
611+
assert!(
612+
matches!(config.encryption, Encryption::File(ref p) if p == "encryption")
613+
);
614+
assert_eq!(
615+
config.encryption().await.unwrap(),
616+
[
617+
0, 0, 17, 17, 34, 34, 51, 51, 68, 68, 85, 85, 102, 102, 119, 119, 136,
618+
136, 153, 153, 170, 170, 187, 187, 204, 204, 221, 221, 238, 238, 255,
619+
255
620+
]
621+
);
622+
623+
let mut key_config = config.keys.key_configs().await.unwrap();
624+
key_config.sort_by_key(|a| {
625+
if let Key::File(p) = &a.key {
626+
Some(p.clone())
627+
} else {
628+
None
629+
}
630+
});
631+
let key_store = config.key_store().await.unwrap();
632+
633+
assert!(key_config[0].kid.is_none());
634+
assert!(matches!(&key_config[0].key, Key::File(p) if p == "keys/key1"));
635+
assert!(key_store.iter().any(|k| k.kid() == Some("xmgGCzGtQFmhEOP0YAqBt-oZyVauSVMXcf4kwcgGZLc")));
636+
assert!(key_config[1].kid.is_none());
637+
assert!(matches!(&key_config[1].key, Key::File(p) if p == "keys/key2"));
638+
assert!(key_store.iter().any(|k| k.kid() == Some("ONUCn80fsiISFWKrVMEiirNVr-QEvi7uQI0QH9q9q4o")));
639+
});
640+
641+
Ok(())
642+
});
643+
})
644+
.await
645+
.unwrap();
646+
}
647+
442648
#[tokio::test]
443649
async fn load_config_inline_secrets() {
444650
task::spawn_blocking(|| {

docs/config.schema.json

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1528,14 +1528,6 @@
15281528
"description": "Application secrets",
15291529
"type": "object",
15301530
"properties": {
1531-
"keys": {
1532-
"description": "List of private keys to use for signing and encrypting payloads",
1533-
"default": [],
1534-
"type": "array",
1535-
"items": {
1536-
"$ref": "#/definitions/KeyConfig"
1537-
}
1538-
},
15391531
"encryption_file": {
15401532
"description": "File containing the encryption key for secure cookies.",
15411533
"type": "string"
@@ -1547,6 +1539,17 @@
15471539
],
15481540
"type": "string",
15491541
"pattern": "[0-9a-fA-F]{64}"
1542+
},
1543+
"keys": {
1544+
"description": "List of private keys to use for signing and encrypting payloads.",
1545+
"type": "array",
1546+
"items": {
1547+
"$ref": "#/definitions/KeyConfig"
1548+
}
1549+
},
1550+
"keys_dir": {
1551+
"description": "Directory of private keys to use for signing and encrypting payloads.",
1552+
"type": "string"
15501553
}
15511554
}
15521555
},

docs/reference/configuration.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ The secret is not updated when the content of the file changes.
222222
> Changing the encryption secret afterwards will lead to a loss of all encrypted
223223
> information in the database.
224224

225-
### `secrets.keys`
225+
### Singing Keys
226226

227227
The service can use a number of key types for signing.
228228
The following key types are supported:
@@ -232,15 +232,26 @@ The following key types are supported:
232232
- ECDSA with the P-384 (`secp384r1`) curve
233233
- ECDSA with the K-256 (`secp256k1`) curve
234234

235-
Each entry in the list corresponds to one signing key used by MAS.
236-
The key can either be specified inline (with the `key` property),
237-
or loaded from a file (with the `key_file` property).
238235
The following key formats are supported:
239236

240237
- PKCS#1 PEM or DER-encoded RSA private key
241238
- PKCS#8 PEM or DER-encoded RSA or ECDSA private key, encrypted or not
242239
- SEC1 PEM or DER-encoded ECDSA private key
243240

241+
The keys can be given as a directory path via `secrets.keys_dir`
242+
or, alternatively, as an inline configuration list via `secrets.keys`.
243+
244+
#### `secrets.keys_dir`
245+
246+
Path to the directory containing MAS signing key files.
247+
Only keys that don’t require a password are supported.
248+
249+
#### `secrets.keys`
250+
251+
Each entry in the list corresponds to one signing key used by MAS.
252+
The key can either be specified inline (with the `key` property),
253+
or loaded from a file (with the `key_file` property).
254+
244255
A [JWK Key ID] is automatically derived from each key.
245256
To override this default, set `kid` to a custom value.
246257
The `kid` can be any case-sensitive string value as long as it is unique to this list;

0 commit comments

Comments
 (0)