stadar 0.1.6

Rust SDK for the stadar.net esports data API.
//! Webhook signature verification helper.
//!
//! Survives openapi-generator regeneration (listed in
//! `clients/rust/.openapi-generator-ignore`).
//!
//! Customers receive Stadar outbound webhooks with an HMAC-SHA256
//! signature in the `X-Stadar-Signature` header (mirrors Polar's
//! pattern from ADR-0019). Returns `Ok(())` on match, an error
//! otherwise. Constant-time comparison via `subtle::ConstantTimeEq`
//! prevents timing-oracle attacks.
//!
//! # Example
//!
//! ```no_run
//! use stadar::webhooks::verify;
//!
//! let body = b"{...}";
//! let sig = "v1=9f8e...";
//! let secret = std::env::var("STADAR_WEBHOOK_SECRET").unwrap();
//! verify(body, sig, &secret).expect("invalid webhook signature");
//! ```

use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use thiserror::Error;

type HmacSha256 = Hmac<Sha256>;

/// Error returned by [`verify`] when the signature does not match.
///
/// We deliberately collapse every failure mode (bad secret, tampered
/// body, malformed header) into a single error variant so callers
/// can't branch on the underlying reason via type matching — the
/// contract is uniform "did this verify or not."
#[derive(Debug, Error, PartialEq, Eq)]
pub enum WebhookError {
    /// The signature did not verify for any reason.
    #[error("stadar: invalid webhook signature")]
    Invalid,
}

/// Verify an inbound Stadar webhook signature.
///
/// `body` is the raw HTTP request body. `signature` is the value of
/// the `X-Stadar-Signature` header (bare hex or `v1=<hex>` variants
/// accepted, comma-separated for rotation). `secret` is the
/// customer's shared webhook secret.
pub fn verify(body: &[u8], signature: &str, secret: &str) -> Result<(), WebhookError> {
    if secret.is_empty() || signature.trim().is_empty() {
        return Err(WebhookError::Invalid);
    }
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| WebhookError::Invalid)?;
    mac.update(body);
    let expected = mac.finalize().into_bytes();

    for candidate in parse_signature_header(signature) {
        if candidate.len() == expected.len()
            && bool::from(candidate.ct_eq(&expected))
        {
            return Ok(());
        }
    }
    Err(WebhookError::Invalid)
}

fn parse_signature_header(header: &str) -> Vec<Vec<u8>> {
    header
        .trim()
        .split(',')
        .filter_map(|raw| {
            let part = raw.trim();
            let hex = match part.find('=') {
                Some(eq) => {
                    if &part[..eq] != "v1" {
                        return None;
                    }
                    &part[eq + 1..]
                }
                None => part,
            };
            decode_hex(hex)
        })
        .collect()
}

fn decode_hex(hex: &str) -> Option<Vec<u8>> {
    if hex.len() % 2 != 0 {
        return None;
    }
    let mut out = Vec::with_capacity(hex.len() / 2);
    let bytes = hex.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        let h = nibble(bytes[i])?;
        let l = nibble(bytes[i + 1])?;
        out.push((h << 4) | l);
        i += 2;
    }
    Some(out)
}

fn nibble(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}