coffrify 0.2.0

Official Rust SDK for Coffrify — encrypted file transfer infrastructure.
Documentation
//! Webhook signature verification.
//!
//! Two formats are supported with auto-detection :
//!
//!  * **Standard Webhooks** (https://www.standardwebhooks.com/) — preferred.
//!    Three headers : `webhook-id`, `webhook-timestamp`, `webhook-signature`
//!    (`v1,<base64>`, space-separated for rotation).
//!  * **Coffrify legacy** — single header `X-Coffrify-Signature: t=<ts>,v1=<hex>`.
//!
//! Pass an array of secrets to [`verify_from_headers`] to accept the previous
//! secret during a rotation grace window.

use base64::{engine::general_purpose::STANDARD, Engine as _};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::{SystemTime, UNIX_EPOCH};

pub const DEFAULT_TOLERANCE_SECONDS: i64 = 300;
type HmacSha256 = Hmac<Sha256>;

#[derive(Debug)]
pub struct VerifyResult {
    pub valid: bool,
    pub event: Option<serde_json::Value>,
    pub reason: Option<&'static str>,
    /// Index of the secret in the input slice that produced the valid signature.
    /// `None` on failure or when only a single secret was supplied.
    pub matched_secret_index: Option<usize>,
}

fn now_secs() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// Resolve a Coffrify secret to its raw key bytes.
/// `whsec_<hex>` is decoded to bytes (Standard Webhooks convention).
/// Anything else is used as-is via its UTF-8 bytes.
fn resolve_key(secret: &str) -> Vec<u8> {
    if let Some(hex_part) = secret.strip_prefix("whsec_") {
        if !hex_part.is_empty() && hex_part.len() % 2 == 0 && hex_part.chars().all(|c| c.is_ascii_hexdigit()) {
            if let Ok(b) = hex::decode(hex_part) {
                return b;
            }
        }
    }
    secret.as_bytes().to_vec()
}

fn hmac_bytes(secret: &str, message: &str) -> Vec<u8> {
    let mut mac = HmacSha256::new_from_slice(&resolve_key(secret)).expect("HMAC key");
    mac.update(message.as_bytes());
    mac.finalize().into_bytes().to_vec()
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff: u8 = 0;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

/// Verify a Coffrify *legacy* `X-Coffrify-Signature` header.
pub fn verify(raw_body: &str, signature_header: &str, secret: &str) -> VerifyResult {
    verify_with_tolerance(raw_body, signature_header, secret, DEFAULT_TOLERANCE_SECONDS)
}

pub fn verify_with_tolerance(
    raw_body: &str,
    signature_header: &str,
    secret: &str,
    tolerance_seconds: i64,
) -> VerifyResult {
    verify_legacy_multi(raw_body, signature_header, &[secret], tolerance_seconds)
}

/// Multi-secret legacy verification — accept either secret during rotation grace.
pub fn verify_legacy_multi(
    raw_body: &str,
    signature_header: &str,
    secrets: &[&str],
    tolerance_seconds: i64,
) -> VerifyResult {
    if signature_header.is_empty() || secrets.is_empty() {
        return VerifyResult { valid: false, event: None, reason: Some("missing signature or secret"), matched_secret_index: None };
    }
    let mut timestamp: Option<&str> = None;
    let mut sig_hex: Option<&str> = None;
    for part in signature_header.split(',') {
        if let Some((k, v)) = part.trim().split_once('=') {
            match k {
                "t" => timestamp = Some(v),
                "v1" => sig_hex = Some(v),
                _ => {}
            }
        }
    }
    let (Some(ts_str), Some(sig)) = (timestamp, sig_hex) else {
        return VerifyResult { valid: false, event: None, reason: Some("malformed signature header"), matched_secret_index: None };
    };
    let ts: i64 = match ts_str.parse() {
        Ok(n) => n,
        Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed timestamp"), matched_secret_index: None },
    };
    if (now_secs() - ts).abs() > tolerance_seconds {
        return VerifyResult { valid: false, event: None, reason: Some("timestamp out of tolerance"), matched_secret_index: None };
    }

    let provided_lower = sig.to_lowercase();
    let provided = match hex::decode(&provided_lower) {
        Ok(b) => b,
        Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed signature bytes"), matched_secret_index: None },
    };
    let message = format!("{}.{}", ts_str, raw_body);

    for (i, s) in secrets.iter().enumerate() {
        let expected = hmac_bytes(s, &message);
        if constant_time_eq(&expected, &provided) {
            let event = serde_json::from_str(raw_body).ok();
            return VerifyResult { valid: true, event, reason: None, matched_secret_index: Some(i) };
        }
    }
    VerifyResult { valid: false, event: None, reason: Some("signature mismatch"), matched_secret_index: None }
}

/// Trait for anything that can give us request headers by name (case-insensitive).
///
/// Implemented for `HashMap<String, String>` and `Vec<(String, String)>`. Easy to
/// adapt to `axum::http::HeaderMap`, `actix_web::HttpRequest`, etc. by wrapping.
pub trait HeaderSource {
    fn header(&self, name: &str) -> Option<String>;
}

impl HeaderSource for HashMap<String, String> {
    fn header(&self, name: &str) -> Option<String> {
        let lower = name.to_ascii_lowercase();
        self.iter()
            .find(|(k, _)| k.to_ascii_lowercase() == lower)
            .map(|(_, v)| v.clone())
    }
}

impl HeaderSource for Vec<(String, String)> {
    fn header(&self, name: &str) -> Option<String> {
        let lower = name.to_ascii_lowercase();
        self.iter()
            .find(|(k, _)| k.to_ascii_lowercase() == lower)
            .map(|(_, v)| v.clone())
    }
}

/// Verify a webhook from a headers source. Auto-detects Standard Webhooks
/// vs the legacy Coffrify format. Standard Webhooks is preferred when present.
///
/// `secrets` accepts one or more values to support rotation grace windows.
pub fn verify_from_headers<H: HeaderSource>(
    raw_body: &str,
    headers: &H,
    secrets: &[&str],
    tolerance_seconds: i64,
) -> VerifyResult {
    if secrets.is_empty() {
        return VerifyResult { valid: false, event: None, reason: Some("missing secret"), matched_secret_index: None };
    }

    let std_id = headers.header("webhook-id");
    let std_ts = headers.header("webhook-timestamp");
    let std_sig = headers.header("webhook-signature");

    if let (Some(id), Some(ts_str), Some(sig)) = (std_id, std_ts, std_sig) {
        let ts: i64 = match ts_str.parse() {
            Ok(n) => n,
            Err(_) => return VerifyResult { valid: false, event: None, reason: Some("malformed timestamp"), matched_secret_index: None },
        };
        if (now_secs() - ts).abs() > tolerance_seconds {
            return VerifyResult { valid: false, event: None, reason: Some("timestamp out of tolerance"), matched_secret_index: None };
        }

        let candidates: Vec<Vec<u8>> = sig
            .split_whitespace()
            .filter_map(|piece| piece.strip_prefix("v1,"))
            .filter_map(|b| STANDARD.decode(b).ok())
            .collect();
        if candidates.is_empty() {
            return VerifyResult { valid: false, event: None, reason: Some("malformed signature"), matched_secret_index: None };
        }

        let message = format!("{}.{}.{}", id, ts, raw_body);
        for (i, s) in secrets.iter().enumerate() {
            let expected = hmac_bytes(s, &message);
            for c in &candidates {
                if constant_time_eq(&expected, c) {
                    let event = serde_json::from_str(raw_body).ok();
                    return VerifyResult { valid: true, event, reason: None, matched_secret_index: Some(i) };
                }
            }
        }
        return VerifyResult { valid: false, event: None, reason: Some("signature mismatch"), matched_secret_index: None };
    }

    // Fall back to legacy.
    if let Some(legacy) = headers.header("x-coffrify-signature") {
        return verify_legacy_multi(raw_body, &legacy, secrets, tolerance_seconds);
    }
    VerifyResult { valid: false, event: None, reason: Some("malformed"), matched_secret_index: None }
}