Skip to main content

alpr/
webhooks.rs

1// Code generated by sdk/generator. DO NOT EDIT.
2
3use 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
12/// How far from now a webhook's timestamp may be.
13pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);
14
15/// How many signatures a request may list: one for the secret in use and one
16/// for each secret still signing during a rotation.
17const MAX_WEBHOOK_SIGNATURES: usize = 6;
18
19/// The payload of `alert.triggered`. The
20/// fields are the body's own, flat: there is no wrapper around them.
21pub type AlertTriggeredWebhookEvent = WebhookAlertTriggered;
22
23/// The payload of `recognition.created`. The
24/// fields are the body's own, flat: there is no wrapper around them.
25pub type RecognitionCreatedWebhookEvent = WebhookRecognitionCreated;
26
27/// The payload of `webhook.test`. The
28/// fields are the body's own, flat: there is no wrapper around them.
29pub type TestWebhookEvent = WebhookTest;
30
31/// An event of a name this SDK does not know yet, kept as it was sent.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub struct UnknownWebhookEvent {
35    /// The event's name.
36    pub event: String,
37    /// The `id` the body carries, or empty when it has none.
38    pub id: String,
39    /// The whole body, decoded.
40    pub body: serde_json::Value,
41}
42
43/// A webhook Alpr.sh sent, verified: one variant a kind of payload, told
44/// apart by [`WebhookEvent::event_name`] where a payload carries several
45/// events, or [`WebhookEvent::Unknown`] for an event this SDK does not know
46/// yet.
47///
48/// The body's `id` is the id of the reading or alert, not of the delivery:
49/// deduplicate deliveries by the `X-Alprsh-Delivery-Id` header.
50#[derive(Debug, Clone, PartialEq)]
51#[non_exhaustive]
52pub enum WebhookEvent {
53    /// `alert.triggered`.
54    AlertTriggered(AlertTriggeredWebhookEvent),
55    /// `recognition.created`.
56    RecognitionCreated(RecognitionCreatedWebhookEvent),
57    /// `webhook.test`.
58    Test(TestWebhookEvent),
59    /// An event this SDK does not know yet.
60    Unknown(UnknownWebhookEvent),
61}
62
63impl WebhookEvent {
64    /// The event's name: `recognition.created`.
65    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    /// The `id` the body carries: the id of the reading or alert, not of the
75    /// delivery.
76    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    /// Reads a verified body into the event its name says. The body must be a
86    /// JSON object with a string `event`.
87    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/// Why a webhook was not accepted.
125#[derive(Debug)]
126#[non_exhaustive]
127pub enum WebhookError {
128    /// The secret is empty: a configuration error, not a bad request.
129    EmptySecret,
130    /// Alpr.sh did not sign the request, or signed it too long ago.
131    InvalidSignature,
132    /// The body is signed but is not an event.
133    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
157/// Checks a webhook Alpr.sh sent and answers its event. Alpr.sh signs the
158/// `X-Alprsh-Timestamp` and `X-Alprsh-Delivery-Id` headers and the body:
159/// `X-Alprsh-Signature` carries `sha256=` and the hex HMAC-SHA256 of
160/// `{timestamp}.{delivery-id}.{body}` under the endpoint's secret (the whole
161/// `whsec_…` text, as it is), and more than one signature while a secret is
162/// rotated: one matching is enough. Pass the body exactly as it arrived,
163/// before parsing it, and deduplicate by the `X-Alprsh-Delivery-Id` header.
164///
165/// `headers` are the request's, their names in any case: a `HashMap`, a list
166/// of pairs or an `http::HeaderMap` by reference. A signature header sent as
167/// several lines is read as one comma-separated value.
168pub 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
181/// [`verify_webhook`] as if it were `now`.
182pub 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
197/// Checks a webhook's signature as [`verify_webhook`] does, without reading
198/// its body.
199pub 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
212/// [`verify_webhook_signature`] as if it were `now`.
213pub 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    // Only ASCII digits: parse also takes a sign, which the signer never sends.
251    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        // One malformed signature refuses the request, as Alpr.sh's own
274        // verifier does.
275        let decoded = signature
276            .trim()
277            .strip_prefix("sha256=")
278            .and_then(decode_hex)
279            .ok_or(WebhookError::InvalidSignature)?;
280        // verify_slice compares in constant time, and every listed signature
281        // is compared.
282        matched |= mac.clone().verify_slice(&decoded).is_ok();
283    }
284    if matched {
285        Ok(())
286    } else {
287        Err(WebhookError::InvalidSignature)
288    }
289}
290
291/// Reads exactly 64 hex digits, either case, as the 32 bytes they write.
292fn 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}