Skip to main content

tango_webhooks/
lib.rs

1//! HMAC-SHA256 signing and verification for Tango webhook deliveries.
2//!
3//! Tango signs each webhook delivery with an HTTP header of the form:
4//!
5//! ```text
6//! X-Tango-Signature: sha256=<lowercase hex HMAC-SHA256 of raw body>
7//! ```
8//!
9//! The signature is computed over the **raw request body bytes**, keyed by the
10//! endpoint's secret. Verify against the bytes you received off the wire —
11//! re-serializing a parsed JSON document will produce a different signature
12//! because of whitespace, key ordering, and float formatting differences.
13//!
14//! This crate has no transport dependency. It pulls in only `hmac`, `sha2`,
15//! `subtle`, and `hex`, so a webhook receiver can verify deliveries without
16//! linking the full SDK.
17//!
18//! # Quick start
19//!
20//! ```
21//! use tango_webhooks::{generate, verify, SIGNATURE_HEADER};
22//!
23//! let body = br#"{"event_type":"alerts.contract.match"}"#;
24//! let secret = "topsecret";
25//!
26//! // Server side (or in tests): produce a signature for `body`.
27//! let header = generate(body, secret);
28//! assert!(header.starts_with("sha256="));
29//!
30//! // Receiver side: verify the header you read off the request.
31//! assert!(verify(body, &header, secret));
32//! assert!(!verify(body, &header, "wrong-secret"));
33//!
34//! // The canonical header name to look up on the request:
35//! assert_eq!(SIGNATURE_HEADER, "X-Tango-Signature");
36//! ```
37//!
38//! # Constant-time comparison
39//!
40//! [`verify`] decodes both signatures to bytes and compares them with
41//! [`subtle::ConstantTimeEq`]. The comparison does not short-circuit on the
42//! first differing byte, which protects against timing-based signature
43//! recovery attacks.
44//!
45//! # Why no axum/actix middleware?
46//!
47//! Transport adapters live behind cargo features added in a later release.
48//! This crate intentionally stays tiny — a verifier service depends on
49//! `tango-webhooks` alone, not the full SDK.
50
51#![forbid(unsafe_code)]
52#![warn(missing_docs)]
53
54use hmac::{Hmac, Mac};
55use sha2::Sha256;
56use subtle::ConstantTimeEq;
57
58type HmacSha256 = Hmac<Sha256>;
59
60/// The HTTP header name Tango uses to sign webhook deliveries.
61pub const SIGNATURE_HEADER: &str = "X-Tango-Signature";
62
63/// The algorithm prefix on the header value (`"sha256="`).
64pub const SIGNATURE_PREFIX: &str = "sha256=";
65
66/// Compute the wire-format signature for `body` keyed by `secret`.
67///
68/// Returns a string of the form `"sha256=<lowercase hex>"`. The hex digest is
69/// 64 characters long (32 bytes of HMAC-SHA256 output).
70///
71/// # Panics
72///
73/// Never. HMAC-SHA256 accepts keys of any length, so the underlying
74/// `Hmac::new_from_slice` call cannot fail; the `expect` documents the
75/// invariant.
76///
77/// # Examples
78///
79/// ```
80/// use tango_webhooks::generate;
81///
82/// let sig = generate(b"hello", "shh");
83/// assert_eq!(
84///     sig,
85///     "sha256=0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70",
86/// );
87/// ```
88#[must_use]
89pub fn generate(body: &[u8], secret: &str) -> String {
90    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
91        .expect("HMAC-SHA256 accepts keys of any length");
92    mac.update(body);
93    let digest = mac.finalize().into_bytes();
94    let mut out = String::with_capacity(SIGNATURE_PREFIX.len() + digest.len() * 2);
95    out.push_str(SIGNATURE_PREFIX);
96    out.push_str(&hex::encode(digest));
97    out
98}
99
100/// Verify that `header` is a valid Tango signature of `body` keyed by `secret`.
101///
102/// Returns `false` for absent, malformed, or mismatched headers — never panics.
103/// The comparison is constant-time via [`subtle::ConstantTimeEq`] on the
104/// decoded digest bytes, so a caller cannot probe a valid signature byte by
105/// byte using response-time differences.
106///
107/// Accepts both the canonical `"sha256=<hex>"` form and a bare hex string
108/// (legacy compatibility, mirroring the Node and Python SDKs).
109///
110/// # Examples
111///
112/// ```
113/// use tango_webhooks::{generate, verify};
114///
115/// let body = b"payload";
116/// let header = generate(body, "secret");
117/// assert!(verify(body, &header, "secret"));
118/// assert!(!verify(body, &header, "wrong-secret"));
119/// assert!(!verify(b"tampered", &header, "secret"));
120/// assert!(!verify(body, "", "secret"));
121/// ```
122#[must_use]
123pub fn verify(body: &[u8], header: &str, secret: &str) -> bool {
124    let Some(parsed) = parse(header) else {
125        return false;
126    };
127    if parsed.algorithm != "sha256" {
128        return false;
129    }
130    // The expected hex is everything after the prefix in `generate`'s output.
131    let expected_full = generate(body, secret);
132    let Some(expected_hex) = expected_full.strip_prefix(SIGNATURE_PREFIX) else {
133        // Should never happen — `generate` always emits the prefix.
134        return false;
135    };
136
137    // Cheap length-based short-circuit before any decoding. Lengths are not
138    // secret, so comparing them in non-constant time is fine.
139    if expected_hex.len() != parsed.signature.len() {
140        return false;
141    }
142
143    let Ok(expected_bytes) = hex::decode(expected_hex) else {
144        return false;
145    };
146    let Ok(actual_bytes) = hex::decode(&parsed.signature) else {
147        return false;
148    };
149
150    expected_bytes.ct_eq(&actual_bytes).into()
151}
152
153/// The decomposed form of an `X-Tango-Signature` header value.
154///
155/// Returned by [`parse`]. `algorithm` is always lowercase; `signature` is the
156/// raw lowercase hex digest with no prefix.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct ParsedSignature {
159    /// The signing algorithm (always `"sha256"` today).
160    pub algorithm: String,
161    /// The lowercase hex digest, without any `sha256=` prefix.
162    pub signature: String,
163}
164
165/// Decompose an `X-Tango-Signature` header value.
166///
167/// Accepts both the canonical `"sha256=<hex>"` form and a bare hex string
168/// (legacy compatibility); in the bare-hex case `algorithm` defaults to
169/// `"sha256"`. Returns `None` for empty, malformed, or non-hex inputs.
170///
171/// # Examples
172///
173/// ```
174/// use tango_webhooks::parse;
175///
176/// let canonical = parse("sha256=0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70")
177///     .expect("canonical form parses");
178/// assert_eq!(canonical.algorithm, "sha256");
179/// assert_eq!(canonical.signature.len(), 64);
180///
181/// // Bare hex (legacy) defaults to sha256.
182/// let bare = parse("0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70")
183///     .expect("bare hex parses");
184/// assert_eq!(bare.algorithm, "sha256");
185///
186/// // Garbage in, None out.
187/// assert!(parse("").is_none());
188/// assert!(parse("   ").is_none());
189/// assert!(parse("sha256=").is_none());
190/// assert!(parse("sha256=zzzz").is_none());
191/// assert!(parse("not-hex").is_none());
192/// ```
193#[must_use]
194pub fn parse(header: &str) -> Option<ParsedSignature> {
195    let stripped = header.trim();
196    if stripped.is_empty() {
197        return None;
198    }
199
200    let (alg, sig) = match stripped.find('=') {
201        Some(0) => return None, // empty algorithm prefix like "=abc"
202        Some(i) => (&stripped[..i], &stripped[i + 1..]),
203        None => ("sha256", stripped),
204    };
205
206    if sig.is_empty() || !is_hex(sig) {
207        return None;
208    }
209
210    Some(ParsedSignature {
211        algorithm: alg.to_ascii_lowercase(),
212        signature: sig.to_ascii_lowercase(),
213    })
214}
215
216fn is_hex(s: &str) -> bool {
217    !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use pretty_assertions::assert_eq;
224
225    /// HMAC-SHA256 of `"hello"` with secret `"shh"`. Same vector the Go,
226    /// Node, and Python SDKs use. Locks the implementation to the canonical
227    /// algorithm — if this changes, every receiver in the world breaks.
228    const KNOWN_VECTOR: &str =
229        "sha256=0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70";
230
231    #[test]
232    fn generate_matches_known_vector() {
233        assert_eq!(generate(b"hello", "shh"), KNOWN_VECTOR);
234    }
235
236    #[test]
237    fn generate_is_deterministic() {
238        // HMAC is deterministic given the same key + message.
239        assert_eq!(generate(b"hello", "shh"), generate(b"hello", "shh"));
240    }
241
242    #[test]
243    fn verify_roundtrip() {
244        let body = br#"{"event":"contract.updated","id":"123"}"#;
245        let header = generate(body, "topsecret");
246        assert!(verify(body, &header, "topsecret"));
247    }
248
249    #[test]
250    fn verify_rejects_wrong_secret() {
251        let body = b"payload";
252        let header = generate(body, "right");
253        assert!(!verify(body, &header, "wrong"));
254    }
255
256    #[test]
257    fn verify_rejects_tampered_body() {
258        let body = b"original";
259        let header = generate(body, "secret");
260        assert!(!verify(b"tampered", &header, "secret"));
261    }
262
263    #[test]
264    fn verify_rejects_empty_header() {
265        assert!(!verify(b"body", "", "secret"));
266        assert!(!verify(b"body", "   ", "secret"));
267    }
268
269    #[test]
270    fn verify_rejects_malformed_headers() {
271        let body = b"body";
272        for h in [
273            "",
274            "    ",
275            "sha256=",
276            "sha256=zzz",
277            "md5=abc",
278            "not-hex",
279            "=abc",
280            "sha256=0e39", // valid hex, wrong length
281        ] {
282            assert!(
283                !verify(body, h, "secret"),
284                "verify unexpectedly accepted malformed header {h:?}",
285            );
286        }
287    }
288
289    #[test]
290    fn verify_accepts_bare_hex_legacy() {
291        let body = b"payload";
292        let with_prefix = generate(body, "s");
293        let bare = with_prefix
294            .strip_prefix(SIGNATURE_PREFIX)
295            .expect("generate always emits the prefix");
296        assert!(verify(body, bare, "s"));
297    }
298
299    #[test]
300    fn verify_is_case_insensitive_on_hex() {
301        let body = b"payload";
302        let header = generate(body, "secret");
303        let upper = header.to_uppercase();
304        // Hex is normalized to lowercase in `parse`, so an uppercase signature
305        // verifies fine.
306        assert!(verify(body, &upper, "secret"));
307    }
308
309    #[test]
310    fn parse_accepts_canonical_form() {
311        let p = parse(KNOWN_VECTOR).expect("canonical form parses");
312        assert_eq!(p.algorithm, "sha256");
313        assert_eq!(
314            p.signature,
315            "0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70",
316        );
317    }
318
319    #[test]
320    fn parse_accepts_bare_hex_form() {
321        let bare = "0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70";
322        let p = parse(bare).expect("bare hex parses");
323        assert_eq!(p.algorithm, "sha256");
324        assert_eq!(p.signature, bare);
325    }
326
327    #[test]
328    fn parse_normalizes_case() {
329        let upper = "SHA256=0E396369EE043C5B6B922743631745B2249CF7CB2C4722E61E802447D5D14C70";
330        let p = parse(upper).expect("uppercase form parses");
331        assert_eq!(p.algorithm, "sha256");
332        assert_eq!(
333            p.signature,
334            "0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70",
335        );
336    }
337
338    #[test]
339    fn parse_trims_whitespace() {
340        let p = parse("   sha256=deadbeef   ").expect("padded form parses");
341        assert_eq!(p.algorithm, "sha256");
342        assert_eq!(p.signature, "deadbeef");
343    }
344
345    #[test]
346    fn parse_rejects_empty_and_whitespace() {
347        assert!(parse("").is_none());
348        assert!(parse("   ").is_none());
349    }
350
351    #[test]
352    fn parse_rejects_empty_signature() {
353        assert!(parse("sha256=").is_none());
354    }
355
356    #[test]
357    fn parse_rejects_empty_algorithm_prefix() {
358        // A leading "=" with nothing before it isn't a valid algorithm name.
359        assert!(parse("=deadbeef").is_none());
360    }
361
362    #[test]
363    fn parse_rejects_non_hex_signature() {
364        assert!(parse("sha256=zzzz").is_none());
365        assert!(parse("sha256=dead beef").is_none());
366        assert!(parse("not-hex").is_none());
367        // Odd-length hex is rejected at verify time (hex::decode fails) and
368        // accepted as a "shape" by parse — that's fine, parse only checks
369        // each char is hex.
370    }
371
372    #[test]
373    fn parse_preserves_non_sha256_algorithm() {
374        // `parse` is content-agnostic; `verify` is the one that gates on alg.
375        let p = parse("md5=deadbeef").expect("any alg parses if hex");
376        assert_eq!(p.algorithm, "md5");
377        assert!(!verify(b"body", "md5=deadbeef", "secret"));
378    }
379
380    #[test]
381    fn parsed_signature_is_clonable_and_comparable() {
382        let a = parse(KNOWN_VECTOR).unwrap();
383        let b = a.clone();
384        assert_eq!(a, b);
385    }
386}