Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ workspace = true
tokio.workspace = true
tracing.workspace = true
anyhow.workspace = true
futures-util.workspace = true

camino = { workspace = true, features = ["serde1"] }
chrono.workspace = true
Expand Down
261 changes: 155 additions & 106 deletions crates/config/src/sections/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use std::borrow::Cow;

use anyhow::{Context, bail};
use camino::Utf8PathBuf;
use futures_util::future::{try_join, try_join_all};
use mas_jose::jwk::{JsonWebKey, JsonWebKeySet};
use mas_keystore::{Encrypter, Keystore, PrivateKey};
use rand::{
Expand All @@ -27,23 +28,160 @@ fn example_secret() -> &'static str {
"0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
}

#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
pub struct KeyConfig {
kid: String,
/// 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<String>")]
#[serde(skip_serializing_if = "Option::is_none")]
password_file: Option<Utf8PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
password: Option<String>,
}

#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
password_file: Option<Utf8PathBuf>,
impl TryFrom<PasswordRaw> for Option<Password> {
type Error = anyhow::Error;

#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
fn try_from(value: PasswordRaw) -> Result<Self, Self::Error> {
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`"),
}
}
}

#[serde(skip_serializing_if = "Option::is_none")]
impl From<Option<Password>> for PasswordRaw {
fn from(value: Option<Password>) -> 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,
},
}
}
}

/// 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<String>")]
#[serde(skip_serializing_if = "Option::is_none")]
key_file: Option<Utf8PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
key: Option<String>,
}

impl TryFrom<KeyRaw> for Key {
type Error = anyhow::Error;

fn try_from(value: KeyRaw) -> Result<Key, Self::Error> {
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<Key> 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)]
pub struct KeyConfig {
kid: String,

#[schemars(with = "PasswordRaw")]
#[serde_as(as = "serde_with::TryFromInto<PasswordRaw>")]
#[serde(flatten)]
password: Option<Password>,

#[schemars(with = "KeyRaw")]
#[serde_as(as = "serde_with::TryFromInto<KeyRaw>")]
#[serde(flatten)]
key: Key,
}

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<Option<Cow<String>>> {
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,
})
}

/// Returns the key.
///
/// If `key_file` was given, the key is read from that file.
async fn key(&self) -> anyhow::Result<Cow<String>> {
Ok(match &self.key {
Key::File(path) => Cow::Owned(tokio::fs::read_to_string(path).await?),
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<JsonWebKey<mas_keystore::PrivateKey>> {
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
Expand Down Expand Up @@ -72,49 +210,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<Keystore> {
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?)),
};

// 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 = JsonWebKey::new(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
Expand All @@ -126,43 +224,6 @@ 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(),
));
}

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(())
}
}

impl SecretsConfig {
Expand All @@ -186,9 +247,7 @@ 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,
key: Key::Value(rsa_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};

let span = tracing::info_span!("ec_p256");
Expand All @@ -204,9 +263,7 @@ 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,
key: Key::Value(ec_p256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};

let span = tracing::info_span!("ec_p384");
Expand All @@ -222,9 +279,7 @@ 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,
key: Key::Value(ec_p384_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};

let span = tracing::info_span!("ec_k256");
Expand All @@ -240,9 +295,7 @@ 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,
key: Key::Value(ec_k256_key.to_pem(pem_rfc7468::LineEnding::LF)?.to_string()),
};

Ok(Self {
Expand All @@ -255,8 +308,7 @@ impl SecretsConfig {
let rsa_key = KeyConfig {
kid: "abcdef".to_owned(),
password: None,
password_file: None,
key: Some(
key: Key::Value(
indoc::indoc! {r"
-----BEGIN PRIVATE KEY-----
MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAymS2RkeIZo7pUeEN
Expand All @@ -271,13 +323,11 @@ impl SecretsConfig {
"}
.to_owned(),
),
key_file: None,
};
let ecdsa_key = KeyConfig {
kid: "ghijkl".to_owned(),
password: None,
password_file: None,
key: Some(
key: Key::Value(
indoc::indoc! {r"
-----BEGIN PRIVATE KEY-----
MIGEAgEAMBAGByqGSM49AgEGBSuBBAAKBG0wawIBAQQgqfn5mYO/5Qq/wOOiWgHA
Expand All @@ -287,7 +337,6 @@ impl SecretsConfig {
"}
.to_owned(),
),
key_file: None,
};

Self {
Expand Down
9 changes: 5 additions & 4 deletions docs/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,7 @@
}
},
"KeyConfig": {
"description": "A single key with its key ID and optional password.",
"type": "object",
"required": [
"kid"
Expand All @@ -1546,17 +1547,17 @@
"kid": {
"type": "string"
},
"password": {
"type": "string"
},
"password_file": {
"type": "string"
},
"key": {
"password": {
"type": "string"
},
"key_file": {
"type": "string"
},
"key": {
"type": "string"
}
}
},
Expand Down
Loading