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}