use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::application::service::sms_write_service::SmsWriteService;
type HmacSha256 = Hmac<Sha256>;
pub const WEBHOOK_SCHEME: &str = "hmac_raw_body";
const MAX_SKEW_SECS: i64 = 300;
#[derive(Debug, thiserror::Error)]
pub enum WebhookError {
#[error("verification failed: {0}")]
Verify(&'static str),
#[error("invalid payload: {0}")]
Invalid(String),
#[error("db: {0}")]
Db(#[from] sqlx::Error),
#[error("sms: {0}")]
Sms(#[from] crate::application::service::sms_write_service::SmsError),
}
#[derive(Debug, Clone, PartialEq)]
pub enum WebhookOutcome {
Advanced,
Replay,
}
pub struct SmsStatusWebhookService {
sms: SmsWriteService,
secret: Vec<u8>,
}
impl SmsStatusWebhookService {
pub fn new(pool: sqlx::PgPool, secret: &str) -> Self {
Self { sms: SmsWriteService::new(pool), secret: secret.as_bytes().to_vec() }
}
pub async fn handle(
&self,
raw_body: &[u8],
signature_header: Option<&str>,
) -> Result<WebhookOutcome, WebhookError> {
let header = signature_header.ok_or(WebhookError::Verify("missing X-Signature"))?;
let given = parse_hex32(header)
.ok_or(WebhookError::Verify("X-Signature is not 64 hex chars"))?;
let expected = mac_raw_body(&self.secret, raw_body);
if !const_eq(&expected, &given) {
return Err(WebhookError::Verify("signature mismatch"));
}
let payload: WebhookPayload = serde_json::from_slice(raw_body)
.map_err(|e| WebhookError::Invalid(format!("body: {e}")))?;
let skew = (chrono::Utc::now() - payload.timestamp).num_seconds().abs();
if skew > MAX_SKEW_SECS {
return Err(WebhookError::Verify("timestamp outside ±5min window"));
}
let state = payload.sms_state()?;
let advanced = self
.sms
.advance_state(
&payload.sms_uuid,
state,
payload.failure_type.as_deref(),
payload.error_message.as_deref(),
)
.await?;
Ok(if advanced { WebhookOutcome::Advanced } else { WebhookOutcome::Replay })
}
}
#[derive(Debug, serde::Deserialize)]
struct WebhookPayload {
timestamp: chrono::DateTime<chrono::Utc>,
sms_uuid: String,
status: String,
failure_type: Option<String>,
error_message: Option<String>,
}
impl WebhookPayload {
fn sms_state(&self) -> Result<&str, WebhookError> {
match self.status.as_str() {
"process" | "pending" | "sent" | "error" => Ok(self.status.as_str()),
other => Err(WebhookError::Invalid(format!(
"status must be process|pending|sent|error, got {other:?}"
))),
}
}
}
fn mac_raw_body(secret: &[u8], raw_body: &[u8]) -> [u8; 32] {
let mut mac = HmacSha256::new_from_slice(secret)
.unwrap_or_else(|_| unreachable!("HMAC-SHA256 accepts any key length"));
mac.update(raw_body);
mac.finalize().into_bytes().into()
}
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let mut out = [0u8; 32];
for (i, pair) in s.as_bytes().chunks(2).enumerate() {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
out[i] = ((hi << 4) | lo) as u8;
}
Some(out)
}
fn const_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_body(status: &str) -> Vec<u8> {
format!(
r#"{{"timestamp":"{}","sms_uuid":"abc","status":"{status}"}}"#,
chrono::Utc::now().to_rfc3339()
)
.into_bytes()
}
fn sig(secret: &[u8], body: &[u8]) -> String {
hex(&mac_raw_body(secret, body))
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn dead_pool() -> sqlx::PgPool {
sqlx::PgPool::connect_lazy(
&std::env::var("MAIL_TEST_DATABASE_URL").unwrap_or_else(|_| {
"postgres://root:password@localhost:5432/backbone_mail_test".into()
}),
)
.unwrap_or_else(|_| panic!("lazy pool handle"))
}
#[tokio::test]
async fn stale_timestamp_rejected_after_signature_passes() {
let svc = SmsStatusWebhookService::new(dead_pool(), "s3cret");
let raw = br#"{"timestamp":"2020-01-01T00:00:00Z","sms_uuid":"x","status":"sent"}"#;
let err = svc.handle(&raw[..], Some(&sig(b"s3cret", &raw[..]))).await.unwrap_err();
assert!(matches!(err, WebhookError::Verify(ref m) if m.contains("window")));
}
#[tokio::test]
async fn wrong_secret_rejected() {
let svc = SmsStatusWebhookService::new(dead_pool(), "s3cret");
let raw = fresh_body("sent");
let err = svc.handle(&raw, Some(&sig(b"other", &raw))).await.unwrap_err();
assert!(matches!(err, WebhookError::Verify(ref m) if m.contains("signature")));
}
#[tokio::test]
async fn missing_or_malformed_header_fail_closed() {
let svc = SmsStatusWebhookService::new(dead_pool(), "s3cret");
let raw = fresh_body("sent");
assert!(matches!(
svc.handle(&raw, None).await.unwrap_err(),
WebhookError::Verify(_)
));
assert!(matches!(
svc.handle(&raw, Some("zz")).await.unwrap_err(),
WebhookError::Verify(_)
));
}
#[tokio::test]
async fn tampered_body_rejected() {
let svc = SmsStatusWebhookService::new(dead_pool(), "s3cret");
let signed = fresh_body("sent");
let sent = fresh_body("error");
let err = svc.handle(&sent, Some(&sig(b"s3cret", &signed))).await.unwrap_err();
assert!(matches!(err, WebhookError::Verify(ref m) if m.contains("signature")));
}
#[tokio::test]
async fn bad_status_vocabulary_rejected_after_verification() {
let svc = SmsStatusWebhookService::new(dead_pool(), "s3cret");
let raw = fresh_body("banana");
assert!(matches!(
svc.handle(&raw, Some(&sig(b"s3cret", &raw))).await.unwrap_err(),
WebhookError::Invalid(_)
));
}
#[test]
fn hex_parsing_rejects_odd_and_non_hex() {
assert!(parse_hex32("ab").is_none());
assert!(parse_hex32(&"z".repeat(64)).is_none());
assert!(parse_hex32(&"0".repeat(63)).is_none());
assert!(parse_hex32(&"0f".repeat(32)).is_some());
}
#[test]
fn const_eq_has_no_length_or_position_leak() {
let a = [7u8; 32];
let mut b = [7u8; 32];
b[31] ^= 1;
assert!(const_eq(&a, &[7u8; 32]));
assert!(!const_eq(&a, &b));
}
}