screenshotfreeapi 1.0.0

Official Rust client for ScreenshotFreeAPI — Screenshot-as-a-Service
Documentation
//! Webhook signature verification.
//!
//! The ScreenshotFreeAPI signs every webhook POST body with HMAC-SHA256 using your
//! webhook secret, and places the hex-encoded digest in the
//! `X-ScreenshotFree-Signature` header.
//!
//! # Example
//!
//! ```rust
//! use screenshotfreeapi::verify_webhook_signature;
//!
//! let body = br#"{"jobId":"abc","status":"completed"}"#;
//! let signature = "2cf24dba..."; // value of X-ScreenshotFree-Signature header
//! let secret = "whsec_my_webhook_secret";
//!
//! match verify_webhook_signature(body, signature, secret) {
//!     Ok(()) => println!("Signature valid — safe to process"),
//!     Err(e) => eprintln!("Rejected: {}", e),
//! }
//! ```

use hmac::{Hmac, Mac};
use sha2::Sha256;

use crate::error::{Result, ScreenshotFreeAPIError};

type HmacSha256 = Hmac<Sha256>;

/// Verify the `X-ScreenshotFree-Signature` header value against a raw request body.
///
/// `body`      — raw bytes of the HTTP request body (do **not** parse before verifying)
/// `signature` — hex string from the `X-ScreenshotFree-Signature` header
/// `secret`    — your webhook signing secret
///
/// Returns `Ok(())` if the signature is valid, or [`ScreenshotFreeAPIError::InvalidSignature`]
/// if it does not match.
///
/// Comparison is performed in constant time to prevent timing attacks.
pub fn verify_webhook_signature(body: &[u8], signature: &str, secret: &str) -> Result<()> {
    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
        .map_err(|_| ScreenshotFreeAPIError::InvalidSignature)?;
    mac.update(body);
    let expected = hex::encode(mac.finalize().into_bytes());

    if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
        return Err(ScreenshotFreeAPIError::InvalidSignature);
    }
    Ok(())
}

/// Constant-time byte-slice comparison — prevents timing oracle attacks.
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_signature(body: &[u8], secret: &str) -> String {
        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
        mac.update(body);
        hex::encode(mac.finalize().into_bytes())
    }

    #[test]
    fn valid_signature_passes() {
        let body = b"{\"jobId\":\"abc\",\"status\":\"completed\"}";
        let secret = "my_webhook_secret";
        let sig = make_signature(body, secret);
        assert!(verify_webhook_signature(body, &sig, secret).is_ok());
    }

    #[test]
    fn wrong_secret_fails() {
        let body = b"{\"jobId\":\"abc\"}";
        let sig = make_signature(body, "correct_secret");
        assert!(verify_webhook_signature(body, &sig, "wrong_secret").is_err());
    }

    #[test]
    fn tampered_body_fails() {
        let original = b"{\"jobId\":\"abc\"}";
        let tampered = b"{\"jobId\":\"xyz\"}";
        let sig = make_signature(original, "secret");
        assert!(verify_webhook_signature(tampered, &sig, "secret").is_err());
    }

    #[test]
    fn length_mismatch_fails() {
        let body = b"hello";
        assert!(verify_webhook_signature(body, "short", "secret").is_err());
    }

    #[test]
    fn empty_body_valid() {
        let body = b"";
        let sig = make_signature(body, "secret");
        assert!(verify_webhook_signature(body, &sig, "secret").is_ok());
    }
}