dregs 1.0.0

The official Rust client for Dregs, which scores the users of your application for fraud and abuse.
Documentation
//! Verifying webhooks Dregs sends you.
//!
//! Dregs signs every webhook with the channel's signing secret: `X-Dregs-Signature` is the
//! hex-encoded HMAC-SHA256 of the raw request body. Verify it before you act on the payload, and
//! verify it against the bytes you received rather than a re-serialized value, because
//! re-serializing changes key order and whitespace and will not match.
//!
//! ```no_run
//! /// Called with the raw body and the `X-Dregs-Signature` header of an incoming request.
//! fn receive(body: &[u8], signature: &str) -> Result<(), dregs::Error> {
//!     let secret = std::env::var("DREGS_WEBHOOK_SECRET").unwrap_or_default();
//!     let event = dregs::webhooks::verify(body, signature, &secret)?;
//!
//!     handle(event);
//!
//!     Ok(())
//! }
//! # fn handle(_: serde_json::Map<String, serde_json::Value>) {}
//! ```
//!
//! The README has a complete handler for axum.
//!
//! The signing secret is shown once, when you create the webhook channel. It is not your API
//! secret key: one authenticates you to Dregs, the other proves a payload came from Dregs.

use std::time::Duration;

use chrono::{DateTime, Utc};
use hmac::{Hmac, KeyInit, Mac};
use serde_json::{Map, Value};
use sha2::Sha256;

use crate::error::{Error, Result};
use crate::models::parse_datetime;

/// The header carrying the payload's signature.
pub const SIGNATURE_HEADER: &str = "X-Dregs-Signature";

/// The header carrying the time the delivery was sent.
pub const TIMESTAMP_HEADER: &str = "X-Dregs-Timestamp";

/// The header naming the webhook event.
pub const EVENT_HEADER: &str = "X-Dregs-Event";

/// How far out of date a webhook's timestamp may be before [`verify`] rejects it.
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);

type HmacSha256 = Hmac<Sha256>;

/// Returns the hex-encoded HMAC-SHA256 of `payload` under `secret`.
pub fn compute_signature(payload: impl AsRef<[u8]>, secret: &str) -> String {
    hex::encode(mac(payload.as_ref(), secret).finalize().into_bytes())
}

/// Returns whether `signature` matches `payload`.
///
/// The comparison is constant-time. Prefer [`verify`], which also rejects replays and hands back
/// the parsed event; reach for this one only when you need the boolean.
pub fn verify_signature(payload: impl AsRef<[u8]>, signature: &str, secret: &str) -> bool {
    if signature.is_empty() || secret.is_empty() {
        return false;
    }

    let Ok(expected) = hex::decode(signature.trim()) else {
        return false;
    };

    mac(payload.as_ref(), secret)
        .verify_slice(&expected)
        .is_ok()
}

/// Verifies a webhook and returns its parsed body: `event`, `timestamp`, and the payload for that
/// event.
///
/// `payload` is the raw request body, exactly as received. `signature` is the
/// `X-Dregs-Signature` header, and `secret` is the channel's signing secret.
///
/// Payloads whose own `timestamp` is more than five minutes from now are rejected as replays. The
/// timestamp is inside the signed body, so an attacker cannot alter it without breaking the
/// signature. Use [`verify_with`] to change the tolerance or skip the check.
///
/// Returns [`Error::WebhookVerification`] when the signature did not match, the body was not a
/// JSON object, or the payload is stale.
pub fn verify(
    payload: impl AsRef<[u8]>,
    signature: &str,
    secret: &str,
) -> Result<Map<String, Value>> {
    verify_with(payload, signature, secret, VerifyOptions::default())
}

/// [`verify`], with a different replay tolerance or reference time.
///
/// ```
/// use dregs::webhooks::{verify_with, VerifyOptions};
///
/// # fn run(body: &[u8], signature: &str, secret: &str) -> dregs::Result<()> {
/// // Only skip the replay check if you deduplicate on the event id yourself.
/// let event = verify_with(body, signature, secret, VerifyOptions::default().tolerance(None))?;
/// # Ok(())
/// # }
/// ```
pub fn verify_with(
    payload: impl AsRef<[u8]>,
    signature: &str,
    secret: &str,
    options: VerifyOptions,
) -> Result<Map<String, Value>> {
    let payload = payload.as_ref();

    if !verify_signature(payload, signature, secret) {
        return Err(failure(
            "The webhook signature did not match. Check that you are verifying the raw request \
             body rather than a re-serialized copy, and that the signing secret belongs to the \
             channel that sent this delivery.",
        ));
    }

    let event: Value = serde_json::from_slice(payload)
        .map_err(|error| failure(format!("The webhook body was not valid JSON: {error}")))?;

    let Value::Object(event) = event else {
        return Err(failure("The webhook body was not a JSON object."));
    };

    if let Some(tolerance) = options.tolerance {
        check_freshness(&event, tolerance, options.now.unwrap_or_else(Utc::now))?;
    }

    Ok(event)
}

/// How [`verify_with`] checks a webhook for replay.
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct VerifyOptions {
    /// How far out of date the payload's own `timestamp` may be before it is treated as a replay.
    /// `None` skips the check, which you should only do if you are deduplicating on the event id
    /// yourself.
    pub tolerance: Option<Duration>,
    /// The time to measure the payload's age against, for tests. Defaults to now.
    pub now: Option<DateTime<Utc>>,
}

impl Default for VerifyOptions {
    fn default() -> Self {
        VerifyOptions {
            tolerance: Some(DEFAULT_TOLERANCE),
            now: None,
        }
    }
}

impl VerifyOptions {
    /// Sets the replay tolerance, or `None` to skip the check.
    pub fn tolerance(mut self, tolerance: Option<Duration>) -> Self {
        self.tolerance = tolerance;
        self
    }

    /// Sets the time to measure the payload's age against.
    pub fn now(mut self, now: DateTime<Utc>) -> Self {
        self.now = Some(now);
        self
    }
}

fn check_freshness(
    event: &Map<String, Value>,
    tolerance: Duration,
    now: DateTime<Utc>,
) -> Result<()> {
    let raw = event
        .get("timestamp")
        .and_then(Value::as_str)
        .filter(|raw| !raw.is_empty())
        .ok_or_else(|| {
            failure(
                "The webhook carried no timestamp, so it cannot be checked for replay. Pass a \
                 tolerance of None if you are deduplicating deliveries some other way.",
            )
        })?;

    let sent = parse_datetime(raw)
        .ok_or_else(|| failure(format!("The webhook timestamp was unreadable: {raw:?}")))?;

    let age = (now - sent).abs().as_seconds_f64();
    let tolerance = tolerance.as_secs_f64();

    if age > tolerance {
        return Err(failure(format!(
            "The webhook timestamp is {age:.0}s away from now, beyond the {tolerance:.0}s \
             tolerance. Treating it as a replay."
        )));
    }

    Ok(())
}

fn mac(payload: &[u8], secret: &str) -> HmacSha256 {
    let mut mac =
        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length");

    mac.update(payload);
    mac
}

fn failure(message: impl Into<String>) -> Error {
    Error::WebhookVerification(message.into())
}