1use std::fmt;
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5
6use hmac::{Hmac, KeyInit, Mac};
7use serde::de::Error as _;
8use sha2::Sha256;
9
10use crate::models::{WebhookAlertTriggered, WebhookRecognitionCreated, WebhookTest};
11
12pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
14
15const MAX_WEBHOOK_SIGNATURES: usize = 6;
18
19pub type AlertTriggeredWebhookEvent = WebhookAlertTriggered;
22
23pub type RecognitionCreatedWebhookEvent = WebhookRecognitionCreated;
26
27pub type TestWebhookEvent = WebhookTest;
30
31#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub struct UnknownWebhookEvent {
35 pub event: String,
37 pub id: String,
39 pub body: serde_json::Value,
41}
42
43#[derive(Debug, Clone, PartialEq)]
51#[non_exhaustive]
52pub enum WebhookEvent {
53 AlertTriggered(AlertTriggeredWebhookEvent),
55 RecognitionCreated(RecognitionCreatedWebhookEvent),
57 Test(TestWebhookEvent),
59 Unknown(UnknownWebhookEvent),
61}
62
63impl WebhookEvent {
64 pub fn event_name(&self) -> &str {
66 match self {
67 WebhookEvent::AlertTriggered(event) => event.event.as_str(),
68 WebhookEvent::RecognitionCreated(event) => event.event.as_str(),
69 WebhookEvent::Test(event) => event.event.as_str(),
70 WebhookEvent::Unknown(event) => &event.event,
71 }
72 }
73
74 pub fn id(&self) -> &str {
77 match self {
78 WebhookEvent::AlertTriggered(event) => &event.id,
79 WebhookEvent::RecognitionCreated(event) => &event.id,
80 WebhookEvent::Test(event) => &event.id,
81 WebhookEvent::Unknown(event) => &event.id,
82 }
83 }
84
85 fn parse(body: &[u8]) -> Result<Self, serde_json::Error> {
88 let value: serde_json::Value = serde_json::from_slice(body)?;
89 let Some(name) = value
90 .get("event")
91 .and_then(serde_json::Value::as_str)
92 .map(str::to_owned)
93 else {
94 return Err(serde_json::Error::custom(
95 "the body is not an event: it has no string `event`",
96 ));
97 };
98 match name.as_str() {
99 "alert.triggered" => {
100 serde_json::from_value(value).map(WebhookEvent::AlertTriggered)
101 }
102 "recognition.created" => {
103 serde_json::from_value(value).map(WebhookEvent::RecognitionCreated)
104 }
105 "webhook.test" => {
106 serde_json::from_value(value).map(WebhookEvent::Test)
107 }
108 _ => {
109 let id = value
110 .get("id")
111 .and_then(serde_json::Value::as_str)
112 .unwrap_or_default()
113 .to_owned();
114 Ok(WebhookEvent::Unknown(UnknownWebhookEvent {
115 event: name,
116 id,
117 body: value,
118 }))
119 }
120 }
121 }
122}
123
124#[derive(Debug)]
126#[non_exhaustive]
127pub enum WebhookError {
128 EmptySecret,
130 InvalidSignature,
132 InvalidPayload(serde_json::Error),
134}
135
136impl fmt::Display for WebhookError {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 match self {
139 WebhookError::EmptySecret => f.write_str("alpr: the webhook secret is empty"),
140 WebhookError::InvalidSignature => {
141 f.write_str("alpr: webhook signature does not verify")
142 }
143 WebhookError::InvalidPayload(error) => write!(f, "alpr: webhook payload: {error}"),
144 }
145 }
146}
147
148impl std::error::Error for WebhookError {
149 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
150 match self {
151 WebhookError::InvalidPayload(error) => Some(error),
152 _ => None,
153 }
154 }
155}
156
157pub fn verify_webhook<I, K, V>(
169 secret: &str,
170 headers: I,
171 body: &[u8],
172) -> Result<WebhookEvent, WebhookError>
173where
174 I: IntoIterator<Item = (K, V)>,
175 K: AsRef<str>,
176 V: AsRef<[u8]>,
177{
178 verify_webhook_at(secret, headers, body, SystemTime::now())
179}
180
181pub fn verify_webhook_at<I, K, V>(
183 secret: &str,
184 headers: I,
185 body: &[u8],
186 now: SystemTime,
187) -> Result<WebhookEvent, WebhookError>
188where
189 I: IntoIterator<Item = (K, V)>,
190 K: AsRef<str>,
191 V: AsRef<[u8]>,
192{
193 verify_webhook_signature_at(secret, headers, body, now)?;
194 WebhookEvent::parse(body).map_err(WebhookError::InvalidPayload)
195}
196
197pub fn verify_webhook_signature<I, K, V>(
200 secret: &str,
201 headers: I,
202 body: &[u8],
203) -> Result<(), WebhookError>
204where
205 I: IntoIterator<Item = (K, V)>,
206 K: AsRef<str>,
207 V: AsRef<[u8]>,
208{
209 verify_webhook_signature_at(secret, headers, body, SystemTime::now())
210}
211
212pub fn verify_webhook_signature_at<I, K, V>(
214 secret: &str,
215 headers: I,
216 body: &[u8],
217 now: SystemTime,
218) -> Result<(), WebhookError>
219where
220 I: IntoIterator<Item = (K, V)>,
221 K: AsRef<str>,
222 V: AsRef<[u8]>,
223{
224 if secret.is_empty() {
225 return Err(WebhookError::EmptySecret);
226 }
227 let (mut id, mut stamp) = (None::<String>, None::<String>);
228 let mut signatures: Vec<String> = Vec::new();
229 for (name, value) in headers {
230 let name = name.as_ref();
231 let wanted = ["x-alprsh-delivery-id", "x-alprsh-timestamp", "x-alprsh-signature"];
232 if !wanted.iter().any(|wanted| name.eq_ignore_ascii_case(wanted)) {
233 continue;
234 }
235 let Ok(value) = std::str::from_utf8(value.as_ref()) else {
236 return Err(WebhookError::InvalidSignature);
237 };
238 if name.eq_ignore_ascii_case("x-alprsh-delivery-id") {
239 id = id.or_else(|| Some(value.to_owned()));
240 } else if name.eq_ignore_ascii_case("x-alprsh-timestamp") {
241 stamp = stamp.or_else(|| Some(value.to_owned()));
242 } else {
243 signatures.push(value.to_owned());
244 }
245 }
246 let id = id
247 .filter(|id| !id.is_empty())
248 .ok_or(WebhookError::InvalidSignature)?;
249 let stamp = stamp.ok_or(WebhookError::InvalidSignature)?;
250 if stamp.is_empty() || !stamp.bytes().all(|byte| byte.is_ascii_digit()) {
252 return Err(WebhookError::InvalidSignature);
253 }
254 let seconds: i64 = stamp.parse().map_err(|_| WebhookError::InvalidSignature)?;
255 let now = match now.duration_since(UNIX_EPOCH) {
256 Ok(since) => i64::try_from(since.as_secs()).unwrap_or(i64::MAX),
257 Err(before) => -i64::try_from(before.duration().as_secs()).unwrap_or(i64::MAX),
258 };
259 if now.abs_diff(seconds) > WEBHOOK_TOLERANCE.as_secs() {
260 return Err(WebhookError::InvalidSignature);
261 }
262 let joined = signatures.join(",");
263 let listed: Vec<&str> = joined.split(',').collect();
264 if listed.len() > MAX_WEBHOOK_SIGNATURES {
265 return Err(WebhookError::InvalidSignature);
266 }
267 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
268 .map_err(|_| WebhookError::EmptySecret)?;
269 mac.update(format!("{stamp}.{id}.").as_bytes());
270 mac.update(body);
271 let mut matched = false;
272 for signature in listed {
273 let decoded = signature
276 .trim()
277 .strip_prefix("sha256=")
278 .and_then(decode_hex)
279 .ok_or(WebhookError::InvalidSignature)?;
280 matched |= mac.clone().verify_slice(&decoded).is_ok();
283 }
284 if matched {
285 Ok(())
286 } else {
287 Err(WebhookError::InvalidSignature)
288 }
289}
290
291fn decode_hex(text: &str) -> Option<[u8; 32]> {
293 let digits = text.as_bytes();
294 if digits.len() != 64 {
295 return None;
296 }
297 let mut out = [0u8; 32];
298 for (index, byte) in out.iter_mut().enumerate() {
299 *byte = (nibble(digits[2 * index])? << 4) | nibble(digits[2 * index + 1])?;
300 }
301 Some(out)
302}
303
304fn nibble(digit: u8) -> Option<u8> {
305 match digit {
306 b'0'..=b'9' => Some(digit - b'0'),
307 b'a'..=b'f' => Some(digit - b'a' + 10),
308 b'A'..=b'F' => Some(digit - b'A' + 10),
309 _ => None,
310 }
311}