Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 = "0.3.31"

camino = { workspace = true, features = ["serde1"] }
chrono.workspace = true
Expand Down
257 changes: 153 additions & 104 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::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,156 @@ fn example_secret() -> &'static str {
"0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff"
}

#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
pub struct KeyConfig {
kid: String,

#[serde(skip_serializing_if = "Option::is_none")]
password: Option<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),
}

#[serde(skip_serializing_if = "Option::is_none")]
/// Password fields as serialized in JSON.
#[derive(JsonSchema, Serialize, Deserialize, Clone, Debug)]
struct PasswordRaw {
#[schemars(with = "Option<String>")]
password_file: Option<Utf8PathBuf>,
password: Option<String>,
}

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

#[serde(skip_serializing_if = "Option::is_none")]
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`"),
}
}
}

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>")]
key_file: Option<Utf8PathBuf>,
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 +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<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 @@ -127,40 +221,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(),
));
}

if key.password.is_some() && key.password_file.is_some() {
return annotate(figment::Error::from(
"Cannot specify both `password` and `password_file`".to_owned(),
));
}
}

fn validate(&self, _figment: &figment::Figment) -> Result<(), figment::Error> {
Ok(())
}
}
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