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;
pub const SIGNATURE_HEADER: &str = "X-Dregs-Signature";
pub const TIMESTAMP_HEADER: &str = "X-Dregs-Timestamp";
pub const EVENT_HEADER: &str = "X-Dregs-Event";
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);
type HmacSha256 = Hmac<Sha256>;
pub fn compute_signature(payload: impl AsRef<[u8]>, secret: &str) -> String {
hex::encode(mac(payload.as_ref(), secret).finalize().into_bytes())
}
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()
}
pub fn verify(
payload: impl AsRef<[u8]>,
signature: &str,
secret: &str,
) -> Result<Map<String, Value>> {
verify_with(payload, signature, secret, VerifyOptions::default())
}
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)
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct VerifyOptions {
pub tolerance: Option<Duration>,
pub now: Option<DateTime<Utc>>,
}
impl Default for VerifyOptions {
fn default() -> Self {
VerifyOptions {
tolerance: Some(DEFAULT_TOLERANCE),
now: None,
}
}
}
impl VerifyOptions {
pub fn tolerance(mut self, tolerance: Option<Duration>) -> Self {
self.tolerance = tolerance;
self
}
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())
}