use std::borrow::Cow;
use crate::error::Error;
use crate::secret::Secret;
use crate::webhook::WebhookEvent;
use crate::webhook::signature::{self, DEFAULT_TOLERANCE_SECS, VerifyError};
pub const DEFAULT_SIGNATURE_HEADER: &str = "Blooio-Signature";
#[derive(Clone)]
pub struct WebhookVerifier {
secret: Secret<String>,
tolerance: u64,
header_name: Cow<'static, str>,
}
impl std::fmt::Debug for WebhookVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebhookVerifier")
.field("tolerance", &self.tolerance)
.field("header_name", &self.header_name)
.finish_non_exhaustive()
}
}
impl WebhookVerifier {
pub fn new(secret: impl Into<Secret<String>>) -> Self {
WebhookVerifier {
secret: secret.into(),
tolerance: DEFAULT_TOLERANCE_SECS,
header_name: Cow::Borrowed(DEFAULT_SIGNATURE_HEADER),
}
}
#[must_use]
pub fn with_tolerance(mut self, tolerance_secs: u64) -> Self {
self.tolerance = tolerance_secs;
self
}
#[must_use]
pub fn with_header_name(mut self, name: impl Into<Cow<'static, str>>) -> Self {
self.header_name = name.into();
self
}
pub fn header_name(&self) -> &str {
&self.header_name
}
pub fn verify_and_parse(
&self,
signature_header: Option<&str>,
body: &[u8],
) -> Result<WebhookEvent, WebhookRejection> {
let sig = signature_header.ok_or(WebhookRejection::MissingSignature)?;
signature::verify(self.secret.expose().as_bytes(), sig, body, self.tolerance)
.map_err(WebhookRejection::InvalidSignature)?;
WebhookEvent::parse(body).map_err(WebhookRejection::Malformed)
}
}
#[derive(Debug, Clone)]
pub struct VerifiedWebhook(pub WebhookEvent);
#[derive(Debug)]
#[non_exhaustive]
pub enum WebhookRejection {
MissingSignature,
InvalidSignature(VerifyError),
Malformed(Error),
BodyRead(String),
}
impl WebhookRejection {
pub fn status_code(&self) -> u16 {
match self {
WebhookRejection::MissingSignature | WebhookRejection::InvalidSignature(_) => 401,
WebhookRejection::Malformed(_) | WebhookRejection::BodyRead(_) => 400,
}
}
}
impl std::fmt::Display for WebhookRejection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WebhookRejection::MissingSignature => f.write_str("missing webhook signature header"),
WebhookRejection::InvalidSignature(e) => write!(f, "invalid webhook signature: {e}"),
WebhookRejection::Malformed(e) => write!(f, "malformed webhook body: {e}"),
WebhookRejection::BodyRead(e) => write!(f, "could not read webhook body: {e}"),
}
}
}
impl std::error::Error for WebhookRejection {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
WebhookRejection::MissingSignature | WebhookRejection::BodyRead(_) => None,
WebhookRejection::InvalidSignature(e) => Some(e),
WebhookRejection::Malformed(e) => Some(e),
}
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::print_stdout
)]
mod tests {
use super::*;
use crate::webhook::MessageEventKind;
const SECRET: &str = "whsec_test_secret";
fn sign(timestamp: i64, body: &[u8]) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
let mut mac = <Hmac<Sha256>>::new_from_slice(SECRET.as_bytes()).unwrap();
mac.update(timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
format!(
"t={timestamp},v1={}",
hex::encode(mac.finalize().into_bytes())
)
}
#[test]
fn missing_signature_is_rejected() {
let v = WebhookVerifier::new(SECRET);
let err = v.verify_and_parse(None, b"{}").unwrap_err();
assert!(matches!(err, WebhookRejection::MissingSignature));
assert_eq!(err.status_code(), 401);
}
#[test]
fn bad_signature_is_rejected() {
let v = WebhookVerifier::new(SECRET);
let err = v
.verify_and_parse(Some("t=1700000000,v1=deadbeef"), b"{}")
.unwrap_err();
assert!(matches!(err, WebhookRejection::InvalidSignature(_)));
assert_eq!(err.status_code(), 401);
}
#[test]
fn valid_signature_parses_event() {
let v = WebhookVerifier::new(SECRET).with_tolerance(u64::MAX);
let body = br#"{"event":"message.received","message_id":"m1"}"#;
let header = sign(1_700_000_000, body);
let ev = v.verify_and_parse(Some(&header), body).unwrap();
assert_eq!(ev.kind(), Some(MessageEventKind::Received));
assert_eq!(ev.payload.message_id.as_deref(), Some("m1"));
}
#[test]
fn default_header_name_is_blooio_signature() {
assert_eq!(
WebhookVerifier::new(SECRET).header_name(),
"Blooio-Signature"
);
assert_eq!(
WebhookVerifier::new(SECRET)
.with_header_name("X-Sig")
.header_name(),
"X-Sig"
);
}
#[test]
fn debug_does_not_leak_secret() {
let dbg = format!("{:?}", WebhookVerifier::new("super-secret"));
assert!(!dbg.contains("super-secret"), "secret leaked in Debug");
}
}