Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions Cargo.lock

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

9 changes: 9 additions & 0 deletions crates/autopilot/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,15 @@ pub async fn run(config: Configuration, shutdown_controller: ShutdownController)
persistence.clone(),
infra::banned::Users::new(
eth.contracts().chainalysis_oracle().clone(),
config
.banned_users
.hermod
.clone()
.map(|hermod| infra::banned::HermodConfig {
url: hermod.url,
hmac_key: hermod.hmac_key,
api_key: hermod.api_key,
}),
config.banned_users.addresses,
config.banned_users.max_cache_size.get().to_u64().unwrap(),
),
Expand Down
94 changes: 93 additions & 1 deletion crates/configs/src/banned_users.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use {
crate::deserialize_env::{deserialize_optional_string_from_env, deserialize_string_from_env},
alloy::primitives::Address,
serde::{Deserialize, Serialize},
std::num::NonZeroUsize,
std::{fmt::Debug, num::NonZeroUsize},
url::Url,
};

fn default_max_cache_size() -> NonZeroUsize {
Expand All @@ -21,17 +23,51 @@ pub struct BannedUsersConfig {
/// Maximum number of entries to keep in the banned users cache.
#[serde(default = "default_max_cache_size")]
pub max_cache_size: NonZeroUsize,

/// Optional Hermod (zeroShadow) sanctioned address checker.
#[serde(default)]
pub hermod: Option<HermodConfig>,
}

impl Default for BannedUsersConfig {
fn default() -> Self {
Self {
addresses: Vec::new(),
max_cache_size: default_max_cache_size(),
hermod: None,
}
}
}

/// Hermod is zeroShadow's self-hosted sanctioned-address checker. Queries
/// are made against an HMAC-SHA256 obfuscated form of the address using a
/// per-customer key.
#[derive(Clone, Deserialize, Serialize)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does HermodConfig need Serialize?

Considering we only use that for tests and it contains secrets in plaintext, it could be an issue if that key ever leaks in the future.

Maybe we can scope this Serialize derive to tests only?

Suggested change
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Deserialize)]
#[cfg_attr(any(test, feature = "test-util"), derive(serde::Serialize))]

#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct HermodConfig {
/// Base URL of the Hermod agent (e.g. `http://hermod:3000`).
pub url: Url,

/// Per-customer HMAC key used to obfuscate addresses before sending.
#[serde(deserialize_with = "deserialize_string_from_env")]
pub hmac_key: String,

/// Optional API key sent as a Bearer token, if the agent was started
/// with `API_KEY` set.
#[serde(default, deserialize_with = "deserialize_optional_string_from_env")]
pub api_key: Option<String>,
}

impl Debug for HermodConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HermodConfig")
.field("url", &self.url)
.field("hmac_key", &"<REDACTED>")
.field("api_key", &self.api_key.as_ref().map(|_| "<REDACTED>"))
.finish()
}
}

#[cfg(test)]
mod tests {
use {super::*, alloy::primitives::address};
Expand All @@ -42,6 +78,7 @@ mod tests {
let config: BannedUsersConfig = toml::from_str(toml).unwrap();
assert!(config.addresses.is_empty());
assert_eq!(config.max_cache_size.get(), 10000);
assert!(config.hermod.is_none());
}

#[test]
Expand All @@ -58,4 +95,59 @@ mod tests {
);
assert_eq!(config.max_cache_size.get(), 5000);
}

#[test]
fn deserialize_with_hermod() {
let toml = r#"
[hermod]
url = "http://hermod:3000"
hmac-key = "key"
api-key = "secret"
"#;
let config: BannedUsersConfig = toml::from_str(toml).unwrap();
let hermod = config.hermod.unwrap();
assert_eq!(hermod.url.as_str(), "http://hermod:3000/");
assert_eq!(hermod.hmac_key, "key");
assert_eq!(hermod.api_key.as_deref(), Some("secret"));
}

#[test]
fn hermod_secrets_redacted() {
let config = HermodConfig {
url: "http://hermod:3000".parse().unwrap(),
hmac_key: "hmac-secret-value".to_string(),
api_key: Some("api-secret-value".to_string()),
};
let debug = format!("{:?}", config);
assert!(debug.contains(r#"hmac_key: "<REDACTED>""#));
assert!(debug.contains(r#"api_key: Some("<REDACTED>")"#));
assert!(!debug.contains("hmac-secret-value"));
assert!(!debug.contains("api-secret-value"));
}

#[test]
fn hermod_secrets_from_env() {
let hmac_var = "TEST_HERMOD_HMAC_KEY";
let api_var = "TEST_HERMOD_API_KEY";
// SAFETY: no other threads access these env vars.
unsafe { std::env::set_var(hmac_var, "env-hmac") };
unsafe { std::env::set_var(api_var, "env-api") };

let toml = format!(
r#"
[hermod]
url = "http://hermod:3000"
hmac-key = "%{hmac_var}"
api-key = "%{api_var}"
"#,
);
let config: BannedUsersConfig = toml::from_str(&toml).unwrap();
let hermod = config.hermod.unwrap();
assert_eq!(hermod.hmac_key, "env-hmac");
assert_eq!(hermod.api_key.as_deref(), Some("env-api"));

// SAFETY: no other threads access these env vars.
unsafe { std::env::remove_var(hmac_var) };
unsafe { std::env::remove_var(api_var) };
}
}
20 changes: 20 additions & 0 deletions crates/configs/src/deserialize_env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ where
}
}

/// Deserializes an optional String from *either* an environment variable —
/// with the format `%<ENV_VAR_NAME>` — or directly from the field value. A
/// missing env var is treated as `None` rather than an error, matching
/// [`deserialize_optional_url_from_env`].
pub(crate) fn deserialize_optional_string_from_env<'de, D>(
deserializer: D,
) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let Some(value) = Option::<String>::deserialize(deserializer)? else {
return Ok(None);
};
match value.strip_prefix(ENV_VAR_PREFIX) {
// In the case of optional variables, we assume a missing env var as empty
Some(env_var_name) => Ok(std::env::var(env_var_name).ok()),
None => Ok(Some(value)),
}
}

/// Deserializes an optional URL from *either* an environment variable — with
/// the format `%<ENV_VAR_NAME>` — or interpreting a String as a URL.
pub(crate) fn deserialize_optional_url_from_env<'de, D>(
Expand Down
5 changes: 5 additions & 0 deletions crates/order-validation/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ license = "MIT OR Apache-2.0"
[dependencies]
alloy-contract = { workspace = true }
alloy-primitives = { workspace = true }
const-hex = { workspace = true }
contracts = { workspace = true }
futures = { workspace = true }
hmac = { workspace = true }
moka = { workspace = true, features = ["sync"] }
reqwest = { workspace = true, features = ["json"] }
sha2 = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }

[lints]
workspace = true
Loading
Loading