alpr-sdk 0.1.1

Alpr.sh's SDK for Rust: license plate recognition from photos, cameras, readings and webhooks.
Documentation
// Code generated by sdk/generator. DO NOT EDIT.

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};

/// How far from now a webhook's timestamp may be.
pub const WEBHOOK_TOLERANCE: Duration = Duration::from_secs(300);

/// How many signatures a request may list: one for the secret in use and one
/// for each secret still signing during a rotation.
const MAX_WEBHOOK_SIGNATURES: usize = 6;

/// The payload of `alert.triggered`. The
/// fields are the body's own, flat: there is no wrapper around them.
pub type AlertTriggeredWebhookEvent = WebhookAlertTriggered;

/// The payload of `recognition.created`. The
/// fields are the body's own, flat: there is no wrapper around them.
pub type RecognitionCreatedWebhookEvent = WebhookRecognitionCreated;

/// The payload of `webhook.test`. The
/// fields are the body's own, flat: there is no wrapper around them.
pub type TestWebhookEvent = WebhookTest;

/// An event of a name this SDK does not know yet, kept as it was sent.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct UnknownWebhookEvent {
    /// The event's name.
    pub event: String,
    /// The `id` the body carries, or empty when it has none.
    pub id: String,
    /// The whole body, decoded.
    pub body: serde_json::Value,
}

/// A webhook Alpr.sh sent, verified: one variant a kind of payload, told
/// apart by [`WebhookEvent::event_name`] where a payload carries several
/// events, or [`WebhookEvent::Unknown`] for an event this SDK does not know
/// yet.
///
/// The body's `id` is the id of the reading or alert, not of the delivery:
/// deduplicate deliveries by the `X-Alprsh-Delivery-Id` header.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum WebhookEvent {
    /// `alert.triggered`.
    AlertTriggered(AlertTriggeredWebhookEvent),
    /// `recognition.created`.
    RecognitionCreated(RecognitionCreatedWebhookEvent),
    /// `webhook.test`.
    Test(TestWebhookEvent),
    /// An event this SDK does not know yet.
    Unknown(UnknownWebhookEvent),
}

impl WebhookEvent {
    /// The event's name: `recognition.created`.
    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,
        }
    }

    /// The `id` the body carries: the id of the reading or alert, not of the
    /// delivery.
    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,
        }
    }

    /// Reads a verified body into the event its name says. The body must be a
    /// JSON object with a string `event`.
    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,
                }))
            }
        }
    }
}

/// Why a webhook was not accepted.
#[derive(Debug)]
#[non_exhaustive]
pub enum WebhookError {
    /// The secret is empty: a configuration error, not a bad request.
    EmptySecret,
    /// Alpr.sh did not sign the request, or signed it too long ago.
    InvalidSignature,
    /// The body is signed but is not an event.
    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,
        }
    }
}

/// Checks a webhook Alpr.sh sent and answers its event. Alpr.sh signs the
/// `X-Alprsh-Timestamp` and `X-Alprsh-Delivery-Id` headers and the body:
/// `X-Alprsh-Signature` carries `sha256=` and the hex HMAC-SHA256 of
/// `{timestamp}.{delivery-id}.{body}` under the endpoint's secret (the whole
/// `whsec_…` text, as it is), and more than one signature while a secret is
/// rotated: one matching is enough. Pass the body exactly as it arrived,
/// before parsing it, and deduplicate by the `X-Alprsh-Delivery-Id` header.
///
/// `headers` are the request's, their names in any case: a `HashMap`, a list
/// of pairs or an `http::HeaderMap` by reference. A signature header sent as
/// several lines is read as one comma-separated value.
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())
}

/// [`verify_webhook`] as if it were `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)
}

/// Checks a webhook's signature as [`verify_webhook`] does, without reading
/// its body.
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())
}

/// [`verify_webhook_signature`] as if it were `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)?;
    // Only ASCII digits: parse also takes a sign, which the signer never sends.
    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 {
        // One malformed signature refuses the request, as Alpr.sh's own
        // verifier does.
        let decoded = signature
            .trim()
            .strip_prefix("sha256=")
            .and_then(decode_hex)
            .ok_or(WebhookError::InvalidSignature)?;
        // verify_slice compares in constant time, and every listed signature
        // is compared.
        matched |= mac.clone().verify_slice(&decoded).is_ok();
    }
    if matched {
        Ok(())
    } else {
        Err(WebhookError::InvalidSignature)
    }
}

/// Reads exactly 64 hex digits, either case, as the 32 bytes they write.
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,
    }
}