use std::fmt;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use serde::de::Error as _;
use sha2::Sha256;
use crate::models::{WebhookAlertTriggered, WebhookRecognitionCreated, WebhookTest};
pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
const MAX_WEBHOOK_SIGNATURES: usize = 6;
pub type AlertTriggeredWebhookEvent = WebhookAlertTriggered;
pub type RecognitionCreatedWebhookEvent = WebhookRecognitionCreated;
pub type TestWebhookEvent = WebhookTest;
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct UnknownWebhookEvent {
pub event: String,
pub id: String,
pub body: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum WebhookEvent {
AlertTriggered(AlertTriggeredWebhookEvent),
RecognitionCreated(RecognitionCreatedWebhookEvent),
Test(TestWebhookEvent),
Unknown(UnknownWebhookEvent),
}
impl WebhookEvent {
pub fn event_name(&self) -> &str {
match self {
WebhookEvent::AlertTriggered(event) => event.event.as_str(),
WebhookEvent::RecognitionCreated(event) => event.event.as_str(),
WebhookEvent::Test(event) => event.event.as_str(),
WebhookEvent::Unknown(event) => &event.event,
}
}
pub fn id(&self) -> &str {
match self {
WebhookEvent::AlertTriggered(event) => &event.id,
WebhookEvent::RecognitionCreated(event) => &event.id,
WebhookEvent::Test(event) => &event.id,
WebhookEvent::Unknown(event) => &event.id,
}
}
fn parse(body: &[u8]) -> Result<Self, serde_json::Error> {
let value: serde_json::Value = serde_json::from_slice(body)?;
let Some(name) = value
.get("event")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
else {
return Err(serde_json::Error::custom(
"the body is not an event: it has no string `event`",
));
};
match name.as_str() {
"alert.triggered" => {
serde_json::from_value(value).map(WebhookEvent::AlertTriggered)
}
"recognition.created" => {
serde_json::from_value(value).map(WebhookEvent::RecognitionCreated)
}
"webhook.test" => {
serde_json::from_value(value).map(WebhookEvent::Test)
}
_ => {
let id = value
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_owned();
Ok(WebhookEvent::Unknown(UnknownWebhookEvent {
event: name,
id,
body: value,
}))
}
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum WebhookError {
EmptySecret,
InvalidSignature,
InvalidPayload(serde_json::Error),
}
impl fmt::Display for WebhookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WebhookError::EmptySecret => f.write_str("alpr: the webhook secret is empty"),
WebhookError::InvalidSignature => {
f.write_str("alpr: webhook signature does not verify")
}
WebhookError::InvalidPayload(error) => write!(f, "alpr: webhook payload: {error}"),
}
}
}
impl std::error::Error for WebhookError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
WebhookError::InvalidPayload(error) => Some(error),
_ => None,
}
}
}
pub fn verify_webhook<I, K, V>(
secret: &str,
headers: I,
body: &[u8],
) -> Result<WebhookEvent, WebhookError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<[u8]>,
{
verify_webhook_at(secret, headers, body, SystemTime::now())
}
pub fn verify_webhook_at<I, K, V>(
secret: &str,
headers: I,
body: &[u8],
now: SystemTime,
) -> Result<WebhookEvent, WebhookError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<[u8]>,
{
verify_webhook_signature_at(secret, headers, body, now)?;
WebhookEvent::parse(body).map_err(WebhookError::InvalidPayload)
}
pub fn verify_webhook_signature<I, K, V>(
secret: &str,
headers: I,
body: &[u8],
) -> Result<(), WebhookError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<[u8]>,
{
verify_webhook_signature_at(secret, headers, body, SystemTime::now())
}
pub fn verify_webhook_signature_at<I, K, V>(
secret: &str,
headers: I,
body: &[u8],
now: SystemTime,
) -> Result<(), WebhookError>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<str>,
V: AsRef<[u8]>,
{
if secret.is_empty() {
return Err(WebhookError::EmptySecret);
}
let (mut id, mut stamp) = (None::<String>, None::<String>);
let mut signatures: Vec<String> = Vec::new();
for (name, value) in headers {
let name = name.as_ref();
let wanted = ["x-alprsh-delivery-id", "x-alprsh-timestamp", "x-alprsh-signature"];
if !wanted.iter().any(|wanted| name.eq_ignore_ascii_case(wanted)) {
continue;
}
let Ok(value) = std::str::from_utf8(value.as_ref()) else {
return Err(WebhookError::InvalidSignature);
};
if name.eq_ignore_ascii_case("x-alprsh-delivery-id") {
id = id.or_else(|| Some(value.to_owned()));
} else if name.eq_ignore_ascii_case("x-alprsh-timestamp") {
stamp = stamp.or_else(|| Some(value.to_owned()));
} else {
signatures.push(value.to_owned());
}
}
let id = id
.filter(|id| !id.is_empty())
.ok_or(WebhookError::InvalidSignature)?;
let stamp = stamp.ok_or(WebhookError::InvalidSignature)?;
if stamp.is_empty() || !stamp.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(WebhookError::InvalidSignature);
}
let seconds: i64 = stamp.parse().map_err(|_| WebhookError::InvalidSignature)?;
let now = match now.duration_since(UNIX_EPOCH) {
Ok(since) => i64::try_from(since.as_secs()).unwrap_or(i64::MAX),
Err(before) => -i64::try_from(before.duration().as_secs()).unwrap_or(i64::MAX),
};
if now.abs_diff(seconds) > WEBHOOK_TOLERANCE.as_secs() {
return Err(WebhookError::InvalidSignature);
}
let joined = signatures.join(",");
let listed: Vec<&str> = joined.split(',').collect();
if listed.len() > MAX_WEBHOOK_SIGNATURES {
return Err(WebhookError::InvalidSignature);
}
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.map_err(|_| WebhookError::EmptySecret)?;
mac.update(format!("{stamp}.{id}.").as_bytes());
mac.update(body);
let mut matched = false;
for signature in listed {
let decoded = signature
.trim()
.strip_prefix("sha256=")
.and_then(decode_hex)
.ok_or(WebhookError::InvalidSignature)?;
matched |= mac.clone().verify_slice(&decoded).is_ok();
}
if matched {
Ok(())
} else {
Err(WebhookError::InvalidSignature)
}
}
fn decode_hex(text: &str) -> Option<[u8; 32]> {
let digits = text.as_bytes();
if digits.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (index, byte) in out.iter_mut().enumerate() {
*byte = (nibble(digits[2 * index])? << 4) | nibble(digits[2 * index + 1])?;
}
Some(out)
}
fn nibble(digit: u8) -> Option<u8> {
match digit {
b'0'..=b'9' => Some(digit - b'0'),
b'a'..=b'f' => Some(digit - b'a' + 10),
b'A'..=b'F' => Some(digit - b'A' + 10),
_ => None,
}
}