emailit 2.0.3

The official Rust SDK for the Emailit Email API
Documentation
//! Webhook signature verification for incoming Emailit webhook events.
//!
//! Use [`verify_webhook_signature`] to validate that a webhook request
//! genuinely originated from Emailit and has not been tampered with.

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::error::{Error, new_api_error};
use crate::types::WebhookEvent;

/// HTTP header name that carries the HMAC-SHA256 signature.
pub const HEADER_SIGNATURE: &str = "x-emailit-signature";

/// HTTP header name that carries the Unix timestamp of the webhook.
pub const HEADER_TIMESTAMP: &str = "x-emailit-timestamp";

/// Default tolerance in seconds for replay-attack protection (5 minutes).
pub const DEFAULT_TOLERANCE: u64 = 300;

type HmacSha256 = Hmac<Sha256>;

/// Verifies an incoming webhook request and returns the parsed event payload.
///
/// Checks the HMAC-SHA256 signature and, when `tolerance` is non-zero,
/// rejects timestamps older than `tolerance` seconds to guard against
/// replay attacks.
///
/// # Arguments
///
/// * `raw_body` -- The raw request body string.
/// * `signature` -- Value of the `x-emailit-signature` header.
/// * `timestamp` -- Value of the `x-emailit-timestamp` header.
/// * `secret` -- Your webhook signing secret.
/// * `tolerance` -- Maximum age in seconds (`None` uses [`DEFAULT_TOLERANCE`]).
///   Pass `Some(0)` to disable timestamp checking.
///
/// # Errors
///
/// Returns [`Error::InvalidRequest`] if the timestamp is unparseable or the
/// JSON payload is malformed, and [`Error::Authentication`] if the signature
/// does not match or the timestamp is too old.
pub fn verify_webhook_signature(
    raw_body: &str,
    signature: &str,
    timestamp: &str,
    secret: &str,
    tolerance: Option<u64>,
) -> Result<WebhookEvent, Error> {
    let tol = tolerance.unwrap_or(DEFAULT_TOLERANCE);

    if tol > 0 {
        let ts: i64 = timestamp
            .parse()
            .map_err(|_| new_api_error(400, "Invalid webhook timestamp.".into(), String::new()))?;

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as i64;

        let age = now - ts;
        if age > tol as i64 {
            return Err(new_api_error(
                401,
                "Webhook timestamp is too old. The request may be a replay attack.".into(),
                String::new(),
            ));
        }
    }

    let computed = compute_webhook_signature(raw_body, timestamp, secret);

    if !constant_time_eq(computed.as_bytes(), signature.as_bytes()) {
        return Err(new_api_error(
            401,
            "Webhook signature verification failed.".into(),
            String::new(),
        ));
    }

    let event: WebhookEvent = serde_json::from_str(raw_body).map_err(|_| {
        new_api_error(
            400,
            "Invalid webhook payload: unable to decode JSON.".into(),
            String::new(),
        )
    })?;

    Ok(event)
}

/// Computes the HMAC-SHA256 signature for a webhook payload.
///
/// The signed payload is `"{timestamp}.{raw_body}"`. This is the same algorithm
/// Emailit uses server-side, so you can use this function to generate expected
/// signatures in tests.
pub fn compute_webhook_signature(raw_body: &str, timestamp: &str, secret: &str) -> String {
    let signed_payload = format!("{}.{}", timestamp, raw_body);
    let mut mac =
        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take any size key");
    mac.update(signed_payload.as_bytes());
    hex::encode(mac.finalize().into_bytes())
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter()
        .zip(b.iter())
        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
        == 0
}