use hmac::{Hmac, KeyInit, Mac};
use sha2::{Digest, Sha256};
use crate::channel::config::{AuthMode, ChannelAuthConfig};
use crate::channel::guards::HeaderLookup;
use crate::config::constant_time_eq;
use crate::errors::OrionError;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub enum CompiledAuth {
ApiKey {
header: String,
scheme: Option<String>,
digests: Vec<[u8; 32]>,
},
Hmac {
header: String,
secret: Vec<u8>,
signature_prefix: Option<String>,
},
}
fn refused() -> OrionError {
OrionError::Unauthorized("Channel authentication failed".into())
}
impl CompiledAuth {
pub async fn compile(cfg: &ChannelAuthConfig) -> Result<Self, String> {
match cfg.mode {
AuthMode::ApiKey => {
let keys = cfg
.keys
.as_ref()
.filter(|k| !k.is_empty())
.ok_or("auth.mode = \"api_key\" requires a non-empty auth.keys")?;
let header = cfg
.header
.clone()
.unwrap_or_else(|| "Authorization".to_string());
let scheme = match cfg.scheme {
Some(ref s) => Some(s.clone()),
None if header.eq_ignore_ascii_case("authorization") => {
Some("Bearer ".to_string())
}
None => None,
};
let mut digests = Vec::with_capacity(keys.len());
for key in keys {
let resolved = resolve_secret(key, "auth.keys").await?;
if resolved.is_empty() {
return Err("auth.keys contains an empty key".to_string());
}
digests.push(Sha256::digest(resolved.as_bytes()).into());
}
Ok(Self::ApiKey {
header,
scheme,
digests,
})
}
AuthMode::Hmac => {
let secret = cfg
.secret
.as_ref()
.ok_or("auth.mode = \"hmac\" requires auth.secret")?;
let secret = resolve_secret(secret, "auth.secret").await?;
if secret.is_empty() {
return Err("auth.secret resolved to an empty value".to_string());
}
Ok(Self::Hmac {
header: cfg
.header
.clone()
.unwrap_or_else(|| "X-Signature".to_string()),
secret: secret.into_bytes(),
signature_prefix: cfg.signature_prefix.clone(),
})
}
}
}
pub fn authenticate(
&self,
header: HeaderLookup<'_>,
raw_body: Option<&[u8]>,
) -> Result<(), OrionError> {
match self {
Self::ApiKey {
header: name,
scheme,
digests,
} => {
let presented = header(name).ok_or_else(refused)?;
let presented = match scheme {
Some(prefix) => presented
.strip_prefix(prefix.as_str())
.ok_or_else(refused)?
.to_string(),
None => presented,
};
let digest: [u8; 32] = Sha256::digest(presented.as_bytes()).into();
if digests.iter().any(|d| constant_time_eq(&digest, d)) {
Ok(())
} else {
Err(refused())
}
}
Self::Hmac {
header: name,
secret,
signature_prefix,
} => {
let presented = header(name).ok_or_else(refused)?;
let presented = match signature_prefix {
Some(prefix) => presented
.strip_prefix(prefix.as_str())
.ok_or_else(refused)?,
None => presented.as_str(),
};
let signature = decode_signature(presented).ok_or_else(refused)?;
let body = raw_body.unwrap_or(&[]);
let mut mac = HmacSha256::new_from_slice(secret).map_err(|_| refused())?;
mac.update(body);
mac.verify_slice(&signature).map_err(|_| refused())
}
}
}
}
fn decode_signature(presented: &str) -> Option<Vec<u8>> {
if let Ok(bytes) = hex::decode(presented) {
return Some(bytes);
}
use base64::Engine;
base64::engine::general_purpose::STANDARD
.decode(presented)
.ok()
}
async fn resolve_secret(value: &str, field: &str) -> Result<String, String> {
let mut json = serde_json::Value::String(value.to_string());
crate::connector::secrets::resolve_in_place(
&mut json,
&crate::connector::secrets::default_resolvers(),
field,
)
.await
.map_err(|e| e.to_string())?;
json.as_str()
.map(str::to_string)
.ok_or_else(|| format!("{field} did not resolve to a string"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::channel::config::ChannelAuthConfig;
fn api_key_config(keys: &[&str]) -> ChannelAuthConfig {
ChannelAuthConfig {
mode: AuthMode::ApiKey,
keys: Some(keys.iter().map(|k| k.to_string()).collect()),
header: None,
scheme: None,
secret: None,
signature_prefix: None,
}
}
fn lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
move |name: &str| {
pairs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(name))
.map(|(_, v)| v.to_string())
}
}
#[tokio::test]
async fn api_key_defaults_to_bearer_on_authorization() {
let auth = CompiledAuth::compile(&api_key_config(&["s3cret"]))
.await
.expect("compiles");
let headers = [("Authorization", "Bearer s3cret")];
assert!(auth.authenticate(&lookup(&headers), None).is_ok());
}
#[tokio::test]
async fn api_key_requires_the_scheme_prefix() {
let auth = CompiledAuth::compile(&api_key_config(&["s3cret"]))
.await
.expect("compiles");
let headers = [("Authorization", "s3cret")];
assert!(auth.authenticate(&lookup(&headers), None).is_err());
}
#[tokio::test]
async fn a_custom_header_takes_a_bare_key() {
let mut cfg = api_key_config(&["s3cret"]);
cfg.header = Some("X-API-Key".to_string());
let auth = CompiledAuth::compile(&cfg).await.expect("compiles");
let headers = [("X-API-Key", "s3cret")];
assert!(auth.authenticate(&lookup(&headers), None).is_ok());
}
#[tokio::test]
async fn a_wrong_or_missing_key_is_refused() {
let auth = CompiledAuth::compile(&api_key_config(&["s3cret"]))
.await
.expect("compiles");
assert!(
auth.authenticate(&lookup(&[("Authorization", "Bearer nope")]), None)
.is_err()
);
assert!(auth.authenticate(&lookup(&[]), None).is_err());
}
#[tokio::test]
async fn any_configured_key_is_accepted() {
let auth = CompiledAuth::compile(&api_key_config(&["old", "new"]))
.await
.expect("compiles");
for key in ["old", "new"] {
let headers = [("Authorization", format!("Bearer {key}"))];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), None).is_ok(), "{key}");
}
}
#[tokio::test]
async fn api_key_mode_requires_keys() {
let mut cfg = api_key_config(&[]);
cfg.keys = None;
assert!(CompiledAuth::compile(&cfg).await.is_err());
let empty = api_key_config(&[]);
assert!(CompiledAuth::compile(&empty).await.is_err());
}
fn hmac_config(secret: &str, prefix: Option<&str>) -> ChannelAuthConfig {
ChannelAuthConfig {
mode: AuthMode::Hmac,
keys: None,
header: Some("X-Signature".to_string()),
scheme: None,
secret: Some(secret.to_string()),
signature_prefix: prefix.map(str::to_string),
}
}
fn sign_hex(secret: &str, body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("hmac key");
mac.update(body);
hex::encode(mac.finalize().into_bytes())
}
#[tokio::test]
async fn hmac_accepts_a_correct_hex_signature() {
let auth = CompiledAuth::compile(&hmac_config("whsec", None))
.await
.expect("compiles");
let body = br#"{"id":"evt_1","amount":2000}"#;
let headers = [("X-Signature", sign_hex("whsec", body))];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), Some(body)).is_ok());
}
#[tokio::test]
async fn hmac_strips_a_configured_signature_prefix() {
let auth = CompiledAuth::compile(&hmac_config("whsec", Some("sha256=")))
.await
.expect("compiles");
let body = br#"{"action":"opened"}"#;
let headers = [("X-Signature", format!("sha256={}", sign_hex("whsec", body)))];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), Some(body)).is_ok());
}
#[tokio::test]
async fn hmac_accepts_a_base64_signature() {
let auth = CompiledAuth::compile(&hmac_config("whsec", None))
.await
.expect("compiles");
let body = br#"{"order":1}"#;
let mut mac = HmacSha256::new_from_slice(b"whsec").expect("hmac key");
mac.update(body);
use base64::Engine;
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let headers = [("X-Signature", sig)];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), Some(body)).is_ok());
}
#[tokio::test]
async fn hmac_refuses_a_tampered_body() {
let auth = CompiledAuth::compile(&hmac_config("whsec", None))
.await
.expect("compiles");
let signed = br#"{"amount":2000}"#;
let tampered = br#"{"amount":9999}"#;
let headers = [("X-Signature", sign_hex("whsec", signed))];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), Some(tampered)).is_err());
}
#[tokio::test]
async fn hmac_refuses_a_signature_from_the_wrong_secret() {
let auth = CompiledAuth::compile(&hmac_config("whsec", None))
.await
.expect("compiles");
let body = br#"{"a":1}"#;
let headers = [("X-Signature", sign_hex("attacker", body))];
let pairs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
assert!(auth.authenticate(&lookup(&pairs), Some(body)).is_err());
}
#[tokio::test]
async fn hmac_refuses_a_malformed_or_absent_signature() {
let auth = CompiledAuth::compile(&hmac_config("whsec", None))
.await
.expect("compiles");
let body = br#"{"a":1}"#;
assert!(
auth.authenticate(&lookup(&[("X-Signature", "not-a-signature!")]), Some(body))
.is_err()
);
assert!(auth.authenticate(&lookup(&[]), Some(body)).is_err());
}
#[tokio::test]
async fn hmac_mode_requires_a_secret() {
let mut cfg = hmac_config("x", None);
cfg.secret = None;
assert!(CompiledAuth::compile(&cfg).await.is_err());
}
#[tokio::test]
async fn every_refusal_carries_the_same_message() {
let auth = CompiledAuth::compile(&api_key_config(&["s3cret"]))
.await
.expect("compiles");
let missing = auth
.authenticate(&lookup(&[]), None)
.expect_err("no header is a refusal");
let wrong = auth
.authenticate(&lookup(&[("Authorization", "Bearer nope")]), None)
.expect_err("a wrong key is a refusal");
assert_eq!(missing.to_string(), wrong.to_string());
}
}