Skip to content

Commit ae71b75

Browse files
authored
config: Refactor parsing of secrets section (#4602)
2 parents c76a58e + d8081c2 commit ae71b75

File tree

4 files changed

+162
-110
lines changed

4 files changed

+162
-110
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/config/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ workspace = true
1515
tokio.workspace = true
1616
tracing.workspace = true
1717
anyhow.workspace = true
18+
futures-util.workspace = true
1819

1920
camino = { workspace = true, features = ["serde1"] }
2021
chrono.workspace = true

crates/config/src/sections/secrets.rs

Lines changed: 155 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use std::borrow::Cow;
88

99
use anyhow::{Context, bail};
1010
use camino::Utf8PathBuf;
11+
use futures_util::future::{try_join, try_join_all};
1112
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
1213
use mas_keystore::{Encrypter, Keystore, PrivateKey};
1314
use rand::{
@@ -27,23 +28,160 @@ fn example_secret() -> &'static str {
2728
"0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
2829
}
2930

30-
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
31-
pub struct KeyConfig {
32-
kid: String,
31+
/// Password config option.
32+
///
33+
/// It either holds the password value directly or references a file where the
34+
/// password is stored.
35+
#[derive(Clone, Debug)]
36+
pub enum Password {
37+
File(Utf8PathBuf),
38+
Value(String),
39+
}
3340

41+
/// Password fields as serialized in JSON.
42+
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
43+
struct PasswordRaw {
44+
#[schemars(with = "Option<String>")]
45+
#[serde(skip_serializing_if = "Option::is_none")]
46+
password_file: Option<Utf8PathBuf>,
3447
#[serde(skip_serializing_if = "Option::is_none")]
3548
password: Option<String>,
49+
}
3650

37-
#[serde(skip_serializing_if = "Option::is_none")]
38-
#[schemars(with = "Option<String>")]
39-
password_file: Option<Utf8PathBuf>,
51+
impl TryFrom<PasswordRaw> for Option<Password> {
52+
type Error = anyhow::Error;
4053

41-
#[serde(skip_serializing_if = "Option::is_none")]
42-
key: Option<String>,
54+
fn try_from(value: PasswordRaw) -> Result<Self, Self::Error> {
55+
match (value.password, value.password_file) {
56+
(None, None) => Ok(None),
57+
(None, Some(path)) => Ok(Some(Password::File(path))),
58+
(Some(password), None) => Ok(Some(Password::Value(password))),
59+
(Some(_), Some(_)) => bail!("Cannot specify both `password` and `password_file`"),
60+
}
61+
}
62+
}
4363

44-
#[serde(skip_serializing_if = "Option::is_none")]
64+
impl From<Option<Password>> for PasswordRaw {
65+
fn from(value: Option<Password>) -> Self {
66+
match value {
67+
Some(Password::File(path)) => PasswordRaw {
68+
password_file: Some(path),
69+
password: None,
70+
},
71+
Some(Password::Value(password)) => PasswordRaw {
72+
password_file: None,
73+
password: Some(password),
74+
},
75+
None => PasswordRaw {
76+
password_file: None,
77+
password: None,
78+
},
79+
}
80+
}
81+
}
82+
83+
/// Key config option.
84+
///
85+
/// It either holds the key value directly or references a file where the key is
86+
/// stored.
87+
#[derive(Clone, Debug)]
88+
pub enum Key {
89+
File(Utf8PathBuf),
90+
Value(String),
91+
}
92+
93+
/// Key fields as serialized in JSON.
94+
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
95+
struct KeyRaw {
4596
#[schemars(with = "Option<String>")]
97+
#[serde(skip_serializing_if = "Option::is_none")]
4698
key_file: Option<Utf8PathBuf>,
99+
#[serde(skip_serializing_if = "Option::is_none")]
100+
key: Option<String>,
101+
}
102+
103+
impl TryFrom<KeyRaw> for Key {
104+
type Error = anyhow::Error;
105+
106+
fn try_from(value: KeyRaw) -> Result<Key, Self::Error> {
107+
match (value.key, value.key_file) {
108+
(None, None) => bail!("Missing `key` or `key_file`"),
109+
(None, Some(path)) => Ok(Key::File(path)),
110+
(Some(key), None) => Ok(Key::Value(key)),
111+
(Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"),
112+
}
113+
}
114+
}
115+
116+
impl From<Key> for KeyRaw {
117+
fn from(value: Key) -> Self {
118+
match value {
119+
Key::File(path) => KeyRaw {
120+
key_file: Some(path),
121+
key: None,
122+
},
123+
Key::Value(key) => KeyRaw {
124+
key_file: None,
125+
key: Some(key),
126+
},
127+
}
128+
}
129+
}
130+
131+
/// A single key with its key ID and optional password.
132+
#[serde_as]
133+
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
134+
pub struct KeyConfig {
135+
kid: 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+
148+
impl KeyConfig {
149+
/// Returns the password in case any is provided.
150+
///
151+
/// If `password_file` was given, the password is read from that file.
152+
async fn password(&self) -> anyhow::Result<Option<Cow<String>>> {
153+
Ok(match &self.password {
154+
Some(Password::File(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)),
155+
Some(Password::Value(password)) => Some(Cow::Borrowed(password)),
156+
None => None,
157+
})
158+
}
159+
160+
/// Returns the key.
161+
///
162+
/// If `key_file` was given, the key is read from that file.
163+
async fn key(&self) -> anyhow::Result<Cow<String>> {
164+
Ok(match &self.key {
165+
Key::File(path) => Cow::Owned(tokio::fs::read_to_string(path).await?),
166+
Key::Value(key) => Cow::Borrowed(key),
167+
})
168+
}
169+
170+
/// Returns the JSON Web Key derived from this key config.
171+
///
172+
/// Password and/or key are read from file if they’re given as path.
173+
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.as_bytes(), password.as_bytes())?,
178+
None => PrivateKey::load(key.as_bytes())?,
179+
};
180+
181+
Ok(JsonWebKey::new(private_key)
182+
.with_kid(self.kid.clone())
183+
.with_use(mas_iana::jose::JsonWebKeyUse::Sig))
184+
}
47185
}
48186

49187
/// Application secrets
@@ -72,49 +210,9 @@ impl SecretsConfig {
72210
/// Returns an error when a key could not be imported
73211
#[tracing::instrument(name = "secrets.load", skip_all)]
74212
pub async fn key_store(&self) -> anyhow::Result<Keystore> {
75-
let mut keys = Vec::with_capacity(self.keys.len());
76-
for item in &self.keys {
77-
let password = match (&item.password, &item.password_file) {
78-
(None, None) => None,
79-
(Some(_), Some(_)) => {
80-
bail!("Cannot specify both `password` and `password_file`")
81-
}
82-
(Some(password), None) => Some(Cow::Borrowed(password)),
83-
(None, Some(path)) => Some(Cow::Owned(tokio::fs::read_to_string(path).await?)),
84-
};
85-
86-
// Read the key either embedded in the config file or on disk
87-
let key = match (&item.key, &item.key_file) {
88-
(None, None) => bail!("Missing `key` or `key_file`"),
89-
(Some(_), Some(_)) => bail!("Cannot specify both `key` and `key_file`"),
90-
(Some(key), None) => {
91-
// If the key was embedded in the config file, assume it is formatted as PEM
92-
if let Some(password) = password {
93-
PrivateKey::load_encrypted_pem(key, password.as_bytes())?
94-
} else {
95-
PrivateKey::load_pem(key)?
96-
}
97-
}
98-
(None, Some(path)) => {
99-
// When reading from disk, it might be either PEM or DER. `PrivateKey::load*`
100-
// will try both.
101-
let key = tokio::fs::read(path).await?;
102-
if let Some(password) = password {
103-
PrivateKey::load_encrypted(&key, password.as_bytes())?
104-
} else {
105-
PrivateKey::load(&key)?
106-
}
107-
}
108-
};
109-
110-
let key = JsonWebKey::new(key)
111-
.with_kid(item.kid.clone())
112-
.with_use(mas_iana::jose::JsonWebKeyUse::Sig);
113-
keys.push(key);
114-
}
213+
let web_keys = try_join_all(self.keys.iter().map(KeyConfig::json_web_key)).await?;
115214

116-
let keys = JsonWebKeySet::new(keys);
117-
Ok(Keystore::new(keys))
215+
Ok(Keystore::new(JsonWebKeySet::new(web_keys)))
118216
}
119217

120218
/// Derive an [`Encrypter`] out of the config
@@ -126,43 +224,6 @@ impl SecretsConfig {
126224

127225
impl ConfigurationSection for SecretsConfig {
128226
const PATH: Option<&'static str> = Some("secrets");
129-
130-
fn validate(&self, figment: &figment::Figment) -> Result<(), figment::Error> {
131-
for (index, key) in self.keys.iter().enumerate() {
132-
let annotate = |mut error: figment::Error| {
133-
error.metadata = figment
134-
.find_metadata(&format!("{root}.keys", root = Self::PATH.unwrap()))
135-
.cloned();
136-
error.profile = Some(figment::Profile::Default);
137-
error.path = vec![
138-
Self::PATH.unwrap().to_owned(),
139-
"keys".to_owned(),
140-
index.to_string(),
141-
];
142-
Err(error)
143-
};
144-
145-
if key.key.is_none() && key.key_file.is_none() {
146-
return annotate(figment::Error::from(
147-
"Missing `key` or `key_file`".to_owned(),
148-
));
149-
}
150-
151-
if key.key.is_some() && key.key_file.is_some() {
152-
return annotate(figment::Error::from(
153-
"Cannot specify both `key` and `key_file`".to_owned(),
154-
));
155-
}
156-
157-
if key.password.is_some() && key.password_file.is_some() {
158-
return annotate(figment::Error::from(
159-
"Cannot specify both `password` and `password_file`".to_owned(),
160-
));
161-
}
162-
}
163-
164-
Ok(())
165-
}
166227
}
167228

168229
impl SecretsConfig {
@@ -186,9 +247,7 @@ impl SecretsConfig {
186247
let rsa_key = KeyConfig {
187248
kid: Alphanumeric.sample_string(&mut rng, 10),
188249
password: None,
189-
password_file: None,
190-
key: Some(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
191-
key_file: None,
250+
key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
192251
};
193252

194253
let span = tracing::info_span!("ec_p256");
@@ -204,9 +263,7 @@ impl SecretsConfig {
204263
let ec_p256_key = KeyConfig {
205264
kid: Alphanumeric.sample_string(&mut rng, 10),
206265
password: None,
207-
password_file: None,
208-
key: Some(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
209-
key_file: None,
266+
key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
210267
};
211268

212269
let span = tracing::info_span!("ec_p384");
@@ -222,9 +279,7 @@ impl SecretsConfig {
222279
let ec_p384_key = KeyConfig {
223280
kid: Alphanumeric.sample_string(&mut rng, 10),
224281
password: None,
225-
password_file: None,
226-
key: Some(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
227-
key_file: None,
282+
key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
228283
};
229284

230285
let span = tracing::info_span!("ec_k256");
@@ -240,9 +295,7 @@ impl SecretsConfig {
240295
let ec_k256_key = KeyConfig {
241296
kid: Alphanumeric.sample_string(&mut rng, 10),
242297
password: None,
243-
password_file: None,
244-
key: Some(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
245-
key_file: None,
298+
key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
246299
};
247300

248301
Ok(Self {
@@ -255,8 +308,7 @@ impl SecretsConfig {
255308
let rsa_key = KeyConfig {
256309
kid: "abcdef".to_owned(),
257310
password: None,
258-
password_file: None,
259-
key: Some(
311+
key: Key::Value(
260312
indoc::indoc! {r"
261313
-----BEGIN PRIVATE KEY-----
262314
MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
@@ -271,13 +323,11 @@ impl SecretsConfig {
271323
"}
272324
.to_owned(),
273325
),
274-
key_file: None,
275326
};
276327
let ecdsa_key = KeyConfig {
277328
kid: "ghijkl".to_owned(),
278329
password: None,
279-
password_file: None,
280-
key: Some(
330+
key: Key::Value(
281331
indoc::indoc! {r"
282332
-----BEGIN PRIVATE KEY-----
283333
MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
@@ -287,7 +337,6 @@ impl SecretsConfig {
287337
"}
288338
.to_owned(),
289339
),
290-
key_file: None,
291340
};
292341

293342
Self {

docs/config.schema.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,7 @@
15381538
}
15391539
},
15401540
"KeyConfig": {
1541+
"description": "A single key with its key ID and optional password.",
15411542
"type": "object",
15421543
"required": [
15431544
"kid"
@@ -1546,17 +1547,17 @@
15461547
"kid": {
15471548
"type": "string"
15481549
},
1549-
"password": {
1550-
"type": "string"
1551-
},
15521550
"password_file": {
15531551
"type": "string"
15541552
},
1555-
"key": {
1553+
"password": {
15561554
"type": "string"
15571555
},
15581556
"key_file": {
15591557
"type": "string"
1558+
},
1559+
"key": {
1560+
"type": "string"
15601561
}
15611562
}
15621563
},

0 commit comments

Comments
 (0)