forjar 1.31.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! FJ-3104: webhook request authentication.
//!
//! # What replaced what
//!
//! This module exists because `webhook_source::compute_hmac_hex` was documented
//! as HMAC-SHA256 — its doc comment even described the ipad/opad construction —
//! while its body computed a keyed BLAKE3 hash. Measured against RFC 4231 TC2
//! (`key = "Jefe"`, `data = "what do ya want for nothing?"`) the old function
//! returned `30f3b0f1…` where HMAC-SHA256 is `5bdcc146…`, so no sender using
//! standard tooling could ever authenticate.
//!
//! The keyed-BLAKE3 was a sound MAC; the defect was truthfulness and
//! interoperability. It survived because **every** signature test computed its
//! expected value by calling the function under test — `f(x) == f(x)`, which
//! cannot detect a wrong algorithm. The tests here are pinned to RFC 4231
//! vectors and to literals generated by `openssl dgst -sha256 -hmac`, i.e. to an
//! oracle outside this crate.
//!
//! # Canonical signed payload
//!
//! A MAC over the body alone replays forever, and with more than one entry in
//! `allowed_paths` a signature minted for `/hooks/deploy` verifies unchanged at
//! `/hooks/destroy`. So the signed bytes bind the timestamp, method and path:
//!
//! ```text
//! t=<unix-seconds>\n<METHOD>\n<request-path>\n<raw-body-bytes>
//! ```
//!
//! Newline-separated rather than Stripe's `t.payload`, because a request path may
//! contain `.` and must not be able to shift the boundary. Neither the method nor
//! the path can contain a newline (HTTP forbids it in the request line), so the
//! encoding is unambiguous.
//!
//! Header, matching Stripe's shape so a comma-separated list stays extensible:
//!
//! ```text
//! X-Forjar-Signature: t=1785350000,v1=<64 hex chars>
//! ```
//!
//! GitHub's `X-Hub-Signature-256: sha256=<hex>` is also accepted, over the bare
//! body, because GitHub cannot be told to sign a custom canonical form.

use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::{HashSet, VecDeque};

type HmacSha256 = Hmac<Sha256>;

/// Length of a SHA-256 MAC in bytes.
const MAC_LEN: usize = 32;

/// Default replay/freshness window in seconds.
pub const DEFAULT_TOLERANCE_SECS: u64 = 300;

/// Compute HMAC-SHA256 of `data` under `key`, lowercase hex.
///
/// Takes bytes, not `&str`: the MAC must cover the exact octets the sender
/// signed. The previous `&str` signature made that impossible by construction,
/// because the server had already run the body through `from_utf8_lossy`.
#[must_use]
pub fn compute_hmac_hex(key: &[u8], data: &[u8]) -> String {
    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
    mac.update(data);
    to_hex(&mac.finalize().into_bytes())
}

/// Verify `provided_hex` against the MAC of `data` under `key`.
///
/// Constant-time: the digest is hex-decoded and handed to `Mac::verify_slice`,
/// which compares under `subtle`'s constant-time equality. The previous code did
/// `sig != &expected` on `String`, which short-circuits on the first differing
/// byte and leaks the length of the matching prefix.
///
/// Hex-decoding first also makes comparison case-insensitive, so an uppercase-hex
/// signature from a correct implementation is accepted rather than rejected.
#[must_use]
pub fn verify_hex(key: &[u8], data: &[u8], provided_hex: &str) -> bool {
    let Some(provided) = decode_hex(provided_hex) else {
        return false;
    };
    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
    mac.update(data);
    mac.verify_slice(&provided).is_ok()
}

/// Build the canonical signed payload: `t=<t>\n<METHOD>\n<path>\n<body>`.
#[must_use]
pub fn canonical_payload(timestamp: i64, method: &str, path: &str, body: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(body.len() + method.len() + path.len() + 32);
    out.extend_from_slice(format!("t={timestamp}\n").as_bytes());
    out.extend_from_slice(method.as_bytes());
    out.push(b'\n');
    out.extend_from_slice(path.as_bytes());
    out.push(b'\n');
    out.extend_from_slice(body);
    out
}

/// Build an `X-Forjar-Signature` header value for a request.
///
/// The counterpart to verification, exposed because a SENDER needs it: without
/// it, every caller re-derives the canonical form by hand and any divergence
/// shows up as an opaque 401. Three test files had each grown their own copy.
#[must_use]
pub fn sign_request(
    secret: &[u8],
    method: &str,
    path: &str,
    body: &[u8],
    timestamp: i64,
) -> String {
    let signed = canonical_payload(timestamp, method, path, body);
    format!("t={timestamp},v1={}", compute_hmac_hex(secret, &signed))
}

/// A parsed `X-Forjar-Signature` header.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SignatureHeader {
    /// The `t=` element, if present and numeric.
    pub timestamp: Option<i64>,
    /// Every `v1=` element, in order. More than one is allowed so a sender can
    /// rotate secrets without a flag day.
    pub v1: Vec<String>,
}

impl SignatureHeader {
    /// Whether the header carried at least one `v1=` element.
    #[must_use]
    pub fn has_v1(&self) -> bool {
        !self.v1.is_empty()
    }
}

/// Parse `t=<unix>,v1=<hex>[,v1=<hex>…]`.
///
/// Unknown elements are ignored so the scheme can grow (Stripe does the same),
/// but a header with no `v1` at all yields `has_v1() == false` and must be
/// rejected by the caller rather than treated as unsigned.
#[must_use]
pub fn parse_forjar_signature(raw: &str) -> SignatureHeader {
    let mut out = SignatureHeader::default();
    for element in raw.split(',') {
        let Some((k, v)) = element.split_once('=') else {
            continue;
        };
        match k.trim() {
            "t" => out.timestamp = v.trim().parse::<i64>().ok(),
            "v1" => out.v1.push(v.trim().to_string()),
            _ => {}
        }
    }
    out
}

/// Extract the hex digest from GitHub's `X-Hub-Signature-256: sha256=<hex>`.
#[must_use]
pub fn parse_github_signature(raw: &str) -> Option<String> {
    raw.trim()
        .strip_prefix("sha256=")
        .map(|h| h.trim().to_string())
}

/// Whether `timestamp` is inside `tolerance_secs` of `now`.
///
/// Absolute difference, so a clock ahead of the receiver is rejected too — a
/// future-dated timestamp would otherwise extend a captured request's validity.
#[must_use]
pub fn timestamp_is_fresh(timestamp: i64, now: i64, tolerance_secs: u64) -> bool {
    let skew = now.saturating_sub(timestamp).unsigned_abs();
    skew <= tolerance_secs
}

/// Seconds since the Unix epoch.
#[must_use]
pub fn unix_now() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
        .unwrap_or(0)
}

/// Bounded, expiring set of already-seen signatures.
///
/// Because the timestamp is inside the signed payload, the `v1` digest is unique
/// per send, so it doubles as the delivery id and the sender needs no extra
/// header to get wrong. `CooldownTracker` is not a substitute: it rate-limits a
/// rulebook, it does not make a delivery exactly-once.
///
/// In-memory rather than persisted: any replay older than the tolerance window is
/// already rejected by the freshness check, so persistence would only cover a
/// ≤`tolerance` sliver after a restart, in exchange for a schema and a migration.
#[derive(Debug)]
pub struct ReplayGuard {
    seen: HashSet<String>,
    order: VecDeque<(i64, String)>,
    tolerance_secs: u64,
    capacity: usize,
}

impl ReplayGuard {
    /// Create a guard holding at most `capacity` entries.
    #[must_use]
    pub fn new(tolerance_secs: u64, capacity: usize) -> Self {
        Self {
            seen: HashSet::new(),
            order: VecDeque::new(),
            tolerance_secs,
            capacity: capacity.max(1),
        }
    }

    /// Record `signature`; returns `false` if it was already seen.
    pub fn admit(&mut self, signature: &str, now: i64) -> bool {
        self.expire(now);
        if !self.seen.insert(signature.to_string()) {
            return false;
        }
        self.order.push_back((now, signature.to_string()));
        while self.order.len() > self.capacity {
            if let Some((_, old)) = self.order.pop_front() {
                self.seen.remove(&old);
            }
        }
        true
    }

    /// Number of retained entries.
    #[must_use]
    pub fn len(&self) -> usize {
        self.seen.len()
    }

    /// Whether the guard is empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.seen.is_empty()
    }

    /// Whether `signature` is currently retained. Lets a caller distinguish
    /// "refused as a duplicate" from "expired and admissible again".
    #[must_use]
    pub fn seen_contains(&self, signature: &str) -> bool {
        self.seen.contains(signature)
    }

    fn expire(&mut self, now: i64) {
        let cutoff = now.saturating_sub(i64::try_from(self.tolerance_secs).unwrap_or(i64::MAX));
        while let Some((seen_at, _)) = self.order.front() {
            if *seen_at >= cutoff {
                break;
            }
            if let Some((_, old)) = self.order.pop_front() {
                self.seen.remove(&old);
            }
        }
    }
}

/// Lowercase hex encoding.
fn to_hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        use std::fmt::Write;
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// Decode exactly `MAC_LEN` bytes of hex, accepting either case.
///
/// Rejects the wrong length and any non-hex character BEFORE the comparison, so
/// a malformed signature is a clean `false` rather than a panic or a truncated
/// compare.
fn decode_hex(s: &str) -> Option<Vec<u8>> {
    let s = s.trim();
    if s.len() != MAC_LEN * 2 {
        return None;
    }
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(MAC_LEN);
    for pair in bytes.chunks(2) {
        let hi = hex_val(pair[0])?;
        let lo = hex_val(pair[1])?;
        out.push((hi << 4) | lo);
    }
    Some(out)
}

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