Skip to main content

cratefield_core/
webhook_signature.rs

1//! Generic webhook signature verification (issue #533, extended by #625).
2//!
3//! [`RoutePolicy::Signature`](crate::route_policy::RoutePolicy::Signature)
4//! used to mean exactly one verifier: `Payments::verify_webhook`, so a
5//! venture receiving **signed** webhooks from a provider that never takes a
6//! payment had no policy it could declare honestly. This module is the other
7//! half: one HMAC core ([`WebhookVerifier`]) with pluggable header schemes
8//! ([`SignatureScheme`]) — Svix ([`Svix`], also what Resend signs with),
9//! Stripe-style ([`StripeStyle`]), GitHub ([`Github`]), Vercel ([`Vercel`],
10//! HMAC-SHA1) and a configured provider layout ([`ProviderScheme`]) — plus a
11//! shared-**token** scheme ([`SharedTokenScheme`], and GitLab ([`Gitlab`]) in
12//! particular) whose header carries the secret itself rather than a signature
13//! of it.
14//!
15//! Verification always runs over the **raw body bytes** — the handler must
16//! read the body before anything parses it — and every path fails closed.
17//!
18//! # SHA-1 and replay
19//!
20//! A scheme picks its HMAC digest through [`SignatureScheme::digest`]:
21//! SHA-256 by default, or SHA-1, which Vercel signs with. SHA-1 is
22//! acceptable here because the construction is an **HMAC**, a message
23//! authentication code: its security rests on the secret key and the hash's
24//! behaviour as a PRF, not on collision resistance, so SHA-1's collisions
25//! are not exploitable against it. It is never used as a collision-sensitive
26//! hash. A shared-token scheme runs no hash at all.
27//!
28//! Neither [`Vercel`] nor the token schemes ([`SharedTokenScheme`],
29//! [`Gitlab`]) cover a timestamp — Vercel's header is a bare body MAC,
30//! GitLab's is the secret verbatim — so none of them stops a replay by
31//! itself. The handler must claim each delivery's event id through the
32//! [`Inbox`](crate::idempotency::Inbox) dedup ledger.
33
34use std::borrow::Cow;
35use std::sync::Arc;
36
37use base64::Engine;
38use base64::engine::general_purpose::STANDARD;
39use hmac::{Hmac, KeyInit, Mac};
40use http::HeaderMap;
41use sha1::Sha1;
42use sha2::Sha256;
43use subtle::ConstantTimeEq;
44
45type HmacSha256 = Hmac<Sha256>;
46type HmacSha1 = Hmac<Sha1>;
47
48/// How far a delivery's timestamp may be from now, in seconds, either way —
49/// Svix's and Stripe's own tolerance, and the [`WebhookVerifier`] default.
50/// The signature stays valid forever; the timestamp is what stops replays.
51pub const DEFAULT_TOLERANCE_SECS: i64 = 300;
52
53/// What a [`SignatureScheme`] read out of one delivery: the bytes the
54/// provider signed, the candidate signatures to compare, the timestamp the
55/// replay tolerance runs on.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct SignedDelivery {
58    /// Exactly the bytes the scheme's MAC ran over — the raw body, or a
59    /// `{timestamp}.{body}` / `{id}.{timestamp}.{body}` layout. Empty for a
60    /// shared-token scheme, which signs nothing.
61    pub signed_payload: Vec<u8>,
62    /// Every candidate the delivery carried, decoded; empty refuses.
63    pub candidates: Vec<Vec<u8>>,
64    /// The Unix seconds the provider says it sent the delivery. `None`
65    /// skips the replay tolerance — the scheme is then only as replay-safe
66    /// as the provider makes it.
67    pub timestamp: Option<i64>,
68}
69
70/// How a provider puts its signature material on the wire. Implement this
71/// for a provider none of the built-in schemes speak; the verification core
72/// is [`WebhookVerifier`]'s either way. Fail closed: `None` for a delivery
73/// whose headers cannot be read in full, never a guess.
74pub trait SignatureScheme: Send + Sync {
75    /// Reads one delivery's signature material out of the request headers
76    /// and the raw body bytes. `None` on any input the scheme cannot fully
77    /// read; an empty candidate list counts as unreadable.
78    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery>;
79
80    /// The key bytes the HMAC runs over: the trimmed secret's own bytes,
81    /// unless the scheme's providers hand out encoded secrets (Svix's
82    /// base64 `whsec_…`), which decode here. `None` refuses.
83    fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
84        let trimmed = secret.trim();
85        (!trimmed.is_empty()).then_some(Cow::Borrowed(trimmed.as_bytes()))
86    }
87
88    /// The digest the scheme's HMAC runs on. SHA-256 unless a provider
89    /// signs with SHA-1 (Vercel today). Ignored by a shared-token scheme,
90    /// which hashes nothing.
91    fn digest(&self) -> Digest {
92        Digest::Sha256
93    }
94
95    /// The signature bytes a matching delivery must carry, given the
96    /// derived `key` and the bytes the scheme signed. The default is
97    /// `HMAC(self.digest(), key, signed_payload)`; a **shared-token** scheme
98    /// overrides this to return the key's own bytes, because for it the
99    /// header *is* the secret and there is nothing to compute.
100    ///
101    /// The verifier compares the result against every candidate in constant
102    /// time — the one comparison both paths share.
103    fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
104        hmac_bytes(self.digest(), key, signed_payload)
105    }
106}
107
108/// The hash an HMAC scheme runs on. SHA-256 everywhere it is not otherwise
109/// named; SHA-1 only where a provider signs with it ([`Vercel`]).
110///
111/// SHA-1 is safe in this position because HMAC is a **MAC**: its security
112/// depends on the secret key and the hash's PRF behaviour, not on collision
113/// resistance. It is not collision-sensitive use.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
115pub enum Digest {
116    /// HMAC-SHA256 — the default, and every scheme before Vercel.
117    #[default]
118    Sha256,
119    /// HMAC-SHA1 — Vercel's `x-vercel-signature`.
120    Sha1,
121}
122
123/// `HMAC(digest, key, payload)` as bytes. HMAC accepts a key of any length,
124/// so the constructor cannot fail.
125fn hmac_bytes(digest: Digest, key: &[u8], payload: &[u8]) -> Vec<u8> {
126    match digest {
127        Digest::Sha256 => {
128            let mut mac =
129                <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
130            mac.update(payload);
131            mac.finalize().into_bytes().to_vec()
132        }
133        Digest::Sha1 => {
134            let mut mac =
135                <HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
136            mac.update(payload);
137            mac.finalize().into_bytes().to_vec()
138        }
139    }
140}
141
142/// The signature core every scheme shares: compute what a matching delivery
143/// must carry — an HMAC (SHA-256 or SHA-1) over the scheme's signed payload,
144/// or, for a token scheme, the secret itself — compare it against **every**
145/// candidate in constant time with no early exit, and hold the delivery to
146/// its timestamp.
147#[derive(Clone)]
148pub struct WebhookVerifier {
149    scheme: Arc<dyn SignatureScheme>,
150    tolerance_secs: i64,
151}
152
153impl WebhookVerifier {
154    /// A verifier with the default tolerance ([`DEFAULT_TOLERANCE_SECS`]).
155    ///
156    /// ```
157    /// use cratefield_core::{Svix, WebhookVerifier};
158    /// use http::HeaderMap;
159    ///
160    /// let verifier = WebhookVerifier::new(Svix);
161    /// // `raw` is the body exactly as it arrived, before any parsing; the
162    /// // empty secret here means the delivery is refused, never guessed at.
163    /// assert!(!verifier.verify("", &HeaderMap::new(), b"{}", 0));
164    /// ```
165    #[must_use]
166    pub fn new(scheme: impl SignatureScheme + 'static) -> Self {
167        Self {
168            scheme: Arc::new(scheme),
169            tolerance_secs: DEFAULT_TOLERANCE_SECS,
170        }
171    }
172
173    /// Overrides the replay tolerance, in seconds, in both directions.
174    #[must_use]
175    pub const fn tolerance_secs(mut self, secs: i64) -> Self {
176        self.tolerance_secs = secs;
177        self
178    }
179
180    /// Whether `body` really was signed for this delivery with `secret`.
181    /// Fails closed on every unreadable input; a timestamp in the future
182    /// counts against the tolerance exactly as a stale one does.
183    ///
184    /// # Panics
185    ///
186    /// Only if the HMAC refused the derived key — which it cannot: HMAC
187    /// accepts keys of any length.
188    #[must_use]
189    pub fn verify(&self, secret: &str, headers: &HeaderMap, body: &[u8], now_unix: i64) -> bool {
190        if secret.trim().is_empty() {
191            return false; // an empty secret is not a key
192        }
193        let Some(delivery) = self.scheme.extract(headers, body) else {
194            return false;
195        };
196        if let Some(sent) = delivery.timestamp
197            && now_unix
198                .saturating_sub(sent)
199                .checked_abs()
200                .is_none_or(|delta| delta > self.tolerance_secs)
201        {
202            return false; // a clock this far out is out of tolerance
203        }
204        let Some(key) = self.scheme.secret_key(secret) else {
205            return false;
206        };
207        // The scheme says what a matching delivery must carry: an HMAC over
208        // the signed payload, or — for a shared-token scheme — the secret
209        // itself. Either way the comparison below is the only one, and it is
210        // constant time.
211        let expected = self
212            .scheme
213            .expected_signature(&key, &delivery.signed_payload);
214        // No early exit: every candidate is compared and the results OR-ed,
215        // so timing says nothing about how much of a wrong signature was
216        // right — nor which candidate (if any) matched.
217        let mut matched = false;
218        for candidate in &delivery.candidates {
219            matched |= bool::from(candidate.as_slice().ct_eq(expected.as_slice()));
220        }
221        matched
222    }
223}
224
225impl std::fmt::Debug for WebhookVerifier {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.debug_struct("WebhookVerifier")
228            .field("tolerance_secs", &self.tolerance_secs)
229            .finish_non_exhaustive()
230    }
231}
232
233/// Svix's scheme — the one Resend, Clerk and a growing list of providers
234/// sign with. Headers `svix-id` / `svix-timestamp` / `svix-signature` (the
235/// `webhook-*` aliases are accepted too); payload `{id}.{timestamp}.{body}`;
236/// signature header a space-separated list of `<version>,<base64>` pairs of
237/// which only `v1` is read — how an endpoint survives a secret rotation.
238#[derive(Debug, Clone, Copy, Default)]
239pub struct Svix;
240
241/// The Svix signing headers: canonical names and `webhook-*` aliases.
242const SVIX_HEADERS: [(&str, &str, &str); 2] = [
243    ("svix-id", "svix-timestamp", "svix-signature"),
244    ("webhook-id", "webhook-timestamp", "webhook-signature"),
245];
246
247impl SignatureScheme for Svix {
248    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
249        let (id, timestamp, signatures) = SVIX_HEADERS.iter().find_map(|(id, at, sig)| {
250            Some((
251                header(headers, id)?,
252                header(headers, at)?,
253                header(headers, sig)?,
254            ))
255        })?;
256        let sent = timestamp.trim().parse::<i64>().ok()?;
257        let mut candidates = Vec::new();
258        for entry in signatures.split_whitespace() {
259            let Some((version, value)) = entry.split_once(',') else {
260                continue;
261            };
262            // Only `v1` is understood; unknown versions are ignored, the way
263            // an endpoint survives a provider adding one.
264            if version == "v1"
265                && let Ok(bytes) = STANDARD.decode(value)
266            {
267                candidates.push(bytes);
268            }
269        }
270        if candidates.is_empty() {
271            return None;
272        }
273        Some(SignedDelivery {
274            signed_payload: [id.as_bytes(), b".", timestamp.as_bytes(), b".", body].concat(),
275            candidates,
276            timestamp: Some(sent),
277        })
278    }
279
280    fn secret_key<'a>(&self, secret: &'a str) -> Option<Cow<'a, [u8]>> {
281        svix_secret_key(secret).map(Cow::Owned)
282    }
283}
284
285/// An Svix endpoint secret's key bytes: base64, with or without the
286/// `whsec_` prefix Svix writes it with. `None` when it does not decode.
287#[must_use]
288pub fn svix_secret_key(secret: &str) -> Option<Vec<u8>> {
289    let trimmed = secret.trim();
290    let encoded = trimmed.strip_prefix("whsec_").unwrap_or(trimmed);
291    STANDARD.decode(encoded).ok().filter(|key| !key.is_empty())
292}
293
294/// Stripe's scheme, the shape `adapter-stripe` verifies: one header of
295/// `t=<unix>,v1=<hex>[,v1=<hex>…]`, payload `{t}.{body}`. The header name is
296/// configurable because other providers use the same layout under their own
297/// name.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct StripeStyle {
300    /// The header carrying `t=…,v1=…`.
301    pub header: &'static str,
302}
303
304impl Default for StripeStyle {
305    fn default() -> Self {
306        Self {
307            header: "Stripe-Signature",
308        }
309    }
310}
311
312impl SignatureScheme for StripeStyle {
313    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
314        let raw = header(headers, self.header)?;
315        let mut timestamp = None;
316        let mut candidates = Vec::new();
317        for part in raw.split(',') {
318            let Some((key, value)) = part.split_once('=') else {
319                continue;
320            };
321            match key.trim() {
322                "t" => timestamp = value.trim().parse::<i64>().ok(),
323                "v1" => {
324                    if let Some(bytes) = hex_decode(value.trim()) {
325                        candidates.push(bytes);
326                    }
327                }
328                _ => {} // unknown keys are ignored, as the adapter ignores them
329            }
330        }
331        let sent = timestamp?;
332        if candidates.is_empty() {
333            return None;
334        }
335        Some(SignedDelivery {
336            signed_payload: [sent.to_string().as_bytes(), b".", body].concat(),
337            candidates,
338            timestamp: Some(sent),
339        })
340    }
341}
342
343/// A provider-specific scheme, configured rather than coded. Entries are
344/// separated by commas or spaces, so a provider may send several — for
345/// instance during a secret rotation.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub struct ProviderScheme {
348    /// The header carrying the signature (or several).
349    pub signature: &'static str,
350    /// How each entry is encoded in the header.
351    pub encoding: SignatureEncoding,
352    /// A literal prefix stripped from each entry before decoding (`sha256=`).
353    pub prefix: Option<&'static str>,
354    /// The header carrying a Unix-seconds timestamp the provider signs.
355    /// When set, the payload is `{timestamp}.{body}` and the replay
356    /// tolerance applies; when `None`, the payload is the raw body.
357    pub timestamp: Option<&'static str>,
358}
359
360impl SignatureScheme for ProviderScheme {
361    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
362        let raw = header(headers, self.signature)?;
363        // A configured timestamp header that is missing or unreadable
364        // refuses; it never downgrades to "no tolerance".
365        let timestamp = match self.timestamp {
366            Some(name) => Some(header(headers, name)?.trim().parse::<i64>().ok()?),
367            None => None,
368        };
369        let mut candidates = Vec::new();
370        for entry in raw.split([',', ' ']) {
371            let Some(entry) = entry.strip_prefix(self.prefix.unwrap_or("")) else {
372                continue;
373            };
374            let decoded = match self.encoding {
375                SignatureEncoding::Hex => hex_decode(entry),
376                SignatureEncoding::Base64 => STANDARD.decode(entry).ok(),
377            };
378            if let Some(bytes) = decoded {
379                candidates.push(bytes);
380            }
381        }
382        if candidates.is_empty() {
383            return None;
384        }
385        Some(SignedDelivery {
386            signed_payload: match timestamp {
387                Some(sent) => [sent.to_string().as_bytes(), b".", body].concat(),
388                None => body.to_vec(),
389            },
390            candidates,
391            timestamp,
392        })
393    }
394}
395
396/// GitHub's scheme (`X-Hub-Signature-256`), the shape GitHub App and
397/// repository webhook deliveries carry: one header, `sha256=<hex>`, over the
398/// **raw body alone**. A named scheme so ventures declaring a GitHub
399/// delivery do not re-type the equivalent [`ProviderScheme`] fields.
400///
401/// GitHub deliveries carry **no timestamp** — only the HMAC — so this
402/// scheme offers no replay protection of its own, and
403/// [`WebhookVerifier::verify`]'s tolerance never applies. A replayed
404/// delivery is rejected by claiming the `X-GitHub-Delivery` id through the
405/// [`Inbox`](crate::idempotency::Inbox) dedup ledger, which is what makes
406/// each delivery apply its effects exactly once.
407#[derive(Debug, Clone, Copy, Default)]
408pub struct Github;
409
410/// The [`ProviderScheme`] GitHub's header layout expands to — kept beside
411/// [`Github`]'s [`SignatureScheme`] impl so the two cannot drift.
412const GITHUB_SCHEME: ProviderScheme = ProviderScheme {
413    signature: "X-Hub-Signature-256",
414    encoding: SignatureEncoding::Hex,
415    prefix: Some("sha256="),
416    timestamp: None,
417};
418
419impl SignatureScheme for Github {
420    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
421        GITHUB_SCHEME.extract(headers, body)
422    }
423}
424
425/// How a provider encodes signature bytes in its header.
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub enum SignatureEncoding {
428    /// Hexadecimal, upper- or lowercase, two digits per byte.
429    Hex,
430    /// Standard base64, with padding.
431    Base64,
432}
433
434/// The `x-vercel-signature` header Vercel signs with.
435const VERCEL_SIGNATURE_HEADER: &str = "x-vercel-signature";
436/// An HMAC-SHA1 tag, the only length [`Vercel`] accepts.
437const SHA1_TAG_LEN: usize = 20;
438
439/// Vercel's scheme: header `x-vercel-signature`, a lowercase hex
440/// HMAC-SHA1 over the raw body — no prefix, no timestamp header. The digest
441/// is SHA-1 but the construction is HMAC, so SHA-1's collisions do not
442/// apply (see the module docs); with no timestamp, replay defence is the
443/// [`Inbox`](crate::idempotency::Inbox) dedup ledger's job.
444#[derive(Debug, Clone, Copy, Default)]
445pub struct Vercel;
446
447impl SignatureScheme for Vercel {
448    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
449        let raw = header(headers, VERCEL_SIGNATURE_HEADER)?;
450        let candidate = hex_decode(raw.trim())?;
451        // Not the 20 bytes of an HMAC-SHA1 tag: malformed, refuse rather
452        // than let a truncated or padded value near the comparison.
453        if candidate.len() != SHA1_TAG_LEN {
454            return None;
455        }
456        Some(SignedDelivery {
457            signed_payload: body.to_vec(),
458            candidates: vec![candidate],
459            timestamp: None,
460        })
461    }
462
463    fn digest(&self) -> Digest {
464        Digest::Sha1
465    }
466}
467
468/// GitLab's `X-Gitlab-Token` header, whose value is the shared secret.
469const GITLAB_TOKEN_HEADER: &str = "X-Gitlab-Token";
470
471/// A shared-**token** scheme: the header carries the secret itself, in the
472/// clear, and a value equal to the secret is what proves the delivery —
473/// there is no signature over the body, so `body` is ignored entirely. The
474/// comparison is the verifier's constant-time one (the same helper the HMAC
475/// path uses); a missing, empty or non-UTF-8 token refuses. Nothing here is
476/// bound to time, so a captured token replays: claim each delivery's event
477/// id through the [`Inbox`](crate::idempotency::Inbox) ledger.
478#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479pub struct SharedTokenScheme {
480    /// The header carrying the token.
481    pub header: &'static str,
482}
483
484impl SignatureScheme for SharedTokenScheme {
485    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
486        // `body` is deliberately unread: a token proves the sender, not the
487        // bytes.
488        let _ = body;
489        let token = header(headers, self.header)?;
490        if token.is_empty() {
491            return None; // an absent token cannot equal a non-empty secret
492        }
493        Some(SignedDelivery {
494            signed_payload: Vec::new(),
495            candidates: vec![token.as_bytes().to_vec()],
496            timestamp: None,
497        })
498    }
499
500    fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
501        let _ = signed_payload;
502        key.to_vec()
503    }
504}
505
506/// GitLab's scheme: header `X-Gitlab-Token` carrying the shared secret
507/// verbatim. Exactly [`SharedTokenScheme`] with that header fixed.
508#[derive(Debug, Clone, Copy, Default)]
509pub struct Gitlab;
510
511/// The one configured instance [`Gitlab`] delegates to.
512const GITLAB_TOKEN: SharedTokenScheme = SharedTokenScheme {
513    header: GITLAB_TOKEN_HEADER,
514};
515
516impl SignatureScheme for Gitlab {
517    fn extract(&self, headers: &HeaderMap, body: &[u8]) -> Option<SignedDelivery> {
518        GITLAB_TOKEN.extract(headers, body)
519    }
520
521    fn expected_signature(&self, key: &[u8], signed_payload: &[u8]) -> Vec<u8> {
522        GITLAB_TOKEN.expected_signature(key, signed_payload)
523    }
524}
525
526/// The first header value, as a string; non-UTF-8 is unreadable, and
527/// unreadable is refused.
528fn header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
529    headers.get(name)?.to_str().ok()
530}
531
532/// Hex, any case, an even number of digits; `None` otherwise.
533fn hex_decode(value: &str) -> Option<Vec<u8>> {
534    if !value.len().is_multiple_of(2) {
535        return None;
536    }
537    (0..value.len())
538        .step_by(2)
539        .map(|at| u8::from_str_radix(&value[at..at + 2], 16).ok())
540        .collect()
541}
542
543#[cfg(test)]
544mod tests {
545    use super::SignatureEncoding::{Base64, Hex};
546    use super::*;
547
548    const SECRET: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
549    const WRONG: &str = "whsec_AAAAAAAA"; // a different, decodable secret
550    const ID: &str = "msg_p5jXN8AQM9LWM0D4loKWxJek";
551    const NOW: i64 = 1_800_000_000;
552    const STRIPE_SECRET: &str = "stripe-endpoint-secret-dummy";
553    const PROVIDER_SECRET: &str = "provider-endpoint-secret-dummy";
554    const VERCEL_SECRET: &str = "vercel-webhook-secret-dummy";
555    const GITLAB_SECRET: &str = "gitlab-shared-token-dummy";
556    /// A Vercel-shaped deployment webhook, the body the documented example
557    /// signs.
558    const VERCEL_BODY: &[u8] =
559        br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_1"}}}"#;
560
561    /// `HMAC-SHA1(key, parts)`, the tag Vercel sends.
562    fn mac_sha1(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
563        let mut mac =
564            <HmacSha1 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
565        for part in parts {
566            mac.update(part);
567        }
568        mac.finalize().into_bytes().to_vec()
569    }
570
571    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
572        pairs
573            .iter()
574            .map(|(name, value)| {
575                let name = name
576                    .parse::<http::header::HeaderName>()
577                    .expect("test header name");
578                (name, value.parse().expect("test header value"))
579            })
580            .collect()
581    }
582
583    fn hex(bytes: &[u8]) -> String {
584        use std::fmt::Write as _;
585        bytes.iter().fold(String::new(), |mut out, byte| {
586            let _ = write!(out, "{byte:02x}");
587            out
588        })
589    }
590
591    fn mac(key: &[u8], parts: &[&[u8]]) -> Vec<u8> {
592        let mut mac =
593            <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
594        for part in parts {
595            mac.update(part);
596        }
597        mac.finalize().into_bytes().to_vec()
598    }
599
600    /// The `svix-signature` header a provider would send for this body.
601    fn svix_sign(secret: &str, id: &str, at: i64, body: &[u8]) -> String {
602        let key = svix_secret_key(secret).expect("the fixture secret decodes");
603        let ts = at.to_string();
604        let payload = [id.as_bytes(), b".", ts.as_bytes(), b".", body];
605        format!("v1,{}", STANDARD.encode(mac(&key, &payload)))
606    }
607
608    fn svix_headers(at: &str, sigs: &str) -> HeaderMap {
609        headers(&[
610            ("svix-id", ID),
611            ("svix-timestamp", at),
612            ("svix-signature", sigs),
613        ])
614    }
615
616    /// Whether an Svix delivery with these header values verifies.
617    fn svix_verifies(secret: &str, at: &str, sigs: &str, body: &[u8], now: i64) -> bool {
618        WebhookVerifier::new(Svix).verify(secret, &svix_headers(at, sigs), body, now)
619    }
620
621    /// The `t=,v1=` header Stripe would send for this body.
622    fn stripe_sign(secret: &str, at: i64, body: &[u8]) -> String {
623        let ts = at.to_string();
624        let payload = [ts.as_bytes(), b".", body];
625        let digest = hex(&mac(secret.as_bytes(), &payload));
626        format!("t={at},v1={digest}")
627    }
628
629    /// Whether a Stripe delivery with this header value verifies.
630    fn stripe_verifies(secret: &str, header: &str, body: &[u8], now: i64) -> bool {
631        let got = headers(&[("Stripe-Signature", header)]);
632        WebhookVerifier::new(StripeStyle::default()).verify(secret, &got, body, now)
633    }
634
635    fn provider(
636        enc: SignatureEncoding,
637        prefix: Option<&'static str>,
638        at: Option<&'static str>,
639    ) -> WebhookVerifier {
640        WebhookVerifier::new(ProviderScheme {
641            signature: "X-Signature",
642            encoding: enc,
643            prefix,
644            timestamp: at,
645        })
646    }
647
648    /// The bare hex signature a hex `ProviderScheme` reads.
649    fn provider_sign(at: Option<i64>, body: &[u8]) -> String {
650        let key = PROVIDER_SECRET.as_bytes();
651        let digest = match at {
652            Some(sent) => mac(key, &[sent.to_string().as_bytes(), b".", body]),
653            None => mac(key, &[body]),
654        };
655        hex(&digest)
656    }
657
658    #[test]
659    fn an_svix_delivery_verifies() {
660        let body = br#"{"type":"pos.order.created"}"#;
661        let header = svix_sign(SECRET, ID, NOW, body);
662        assert!(svix_verifies(SECRET, "1800000000", &header, body, NOW));
663        // `webhook-*` aliases, and the secret without its `whsec_` prefix.
664        let aliased = headers(&[
665            ("webhook-id", ID),
666            ("webhook-timestamp", "1800000000"),
667            ("webhook-signature", &header),
668        ]);
669        assert!(WebhookVerifier::new(Svix).verify(SECRET, &aliased, body, NOW));
670        let bare = SECRET.strip_prefix("whsec_").expect("prefixed");
671        assert!(svix_verifies(bare, "1800000000", &header, body, NOW));
672    }
673
674    #[test]
675    fn a_tampered_delivery_or_wrong_secret_refuses_but_a_rotation_matches() {
676        let body = b"{}";
677        let header = svix_sign(SECRET, ID, NOW, body);
678        let old = svix_sign(WRONG, ID, NOW, body);
679        let tampered = br#"{"changed":true}"#;
680        assert!(!svix_verifies(SECRET, "1800000000", &header, tampered, NOW));
681        assert!(!svix_verifies(WRONG, "1800000000", &header, body, NOW));
682        // The id is part of what is signed, so it cannot be swapped either.
683        let swapped = svix_sign(SECRET, "msg_another", NOW, body);
684        assert!(!svix_verifies(SECRET, "1800000000", &swapped, body, NOW));
685        // A rotation: the provider signs with both secrets for the overlap,
686        // an unknown version among them is ignored, and a header holding
687        // only unknown versions refuses.
688        let both = format!("{old} v0,AAAA {header}");
689        assert!(svix_verifies(SECRET, "1800000000", &both, body, NOW));
690        assert!(!svix_verifies(SECRET, "1800000000", "v0,AAAA", body, NOW));
691    }
692
693    #[test]
694    fn a_delivery_outside_the_tolerance_is_refused() {
695        let body = b"{}";
696        let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
697        let stale = svix_sign(SECRET, ID, sent, body);
698        // The signature stays perfectly valid; the timestamp is what stops
699        // it being replayed forever — in either direction.
700        assert!(svix_verifies(SECRET, &sent.to_string(), &stale, body, sent));
701        assert!(!svix_verifies(SECRET, &sent.to_string(), &stale, body, NOW));
702        let future = NOW + DEFAULT_TOLERANCE_SECS + 1;
703        let late = svix_sign(SECRET, ID, future, body);
704        assert!(!svix_verifies(
705            SECRET,
706            &future.to_string(),
707            &late,
708            body,
709            NOW
710        ));
711        // The tolerance is configurable.
712        let wide = WebhookVerifier::new(Svix).tolerance_secs(DEFAULT_TOLERANCE_SECS * 2);
713        assert!(wide.verify(SECRET, &svix_headers(&sent.to_string(), &stale), body, NOW));
714    }
715
716    #[test]
717    fn unreadable_svix_input_refuses() {
718        let body = b"{}";
719        let header = svix_sign(SECRET, ID, NOW, body);
720        // Missing headers entirely; a timestamp that is not a number; a
721        // signature list with no readable entry.
722        assert!(!WebhookVerifier::new(Svix).verify(SECRET, &headers(&[]), body, NOW));
723        assert!(!svix_verifies(SECRET, "recently", &header, body, NOW));
724        assert!(!svix_verifies(SECRET, "1800000000", "garbage", body, NOW));
725        // A secret that does not decode is a misconfiguration, not a
726        // fallback; an empty secret is not a key.
727        assert!(!svix_verifies(
728            "whsec_!!!!",
729            "1800000000",
730            &header,
731            body,
732            NOW
733        ));
734        assert!(!svix_verifies("", "1800000000", &header, body, NOW));
735    }
736
737    #[test]
738    fn a_stripe_delivery_verifies_and_malformed_ones_refuse() {
739        let body = b"{}";
740        let header = stripe_sign(STRIPE_SECRET, NOW, body);
741        assert!(stripe_verifies(STRIPE_SECRET, &header, body, NOW));
742        // The header name is configurable: the same layout under another.
743        let renamed = WebhookVerifier::new(StripeStyle {
744            header: "X-Signature",
745        });
746        let under = headers(&[("X-Signature", &header)]);
747        assert!(renamed.verify(STRIPE_SECRET, &under, body, NOW));
748        // Body changed; another endpoint's secret.
749        assert!(!stripe_verifies(STRIPE_SECRET, &header, b"x", NOW));
750        assert!(!stripe_verifies("another-secret-dummy", &header, body, NOW));
751        // No `t=` (nothing to hold the tolerance to), a `t=` that is not a
752        // number, and no `v1=` at all.
753        let digest = hex(&mac(
754            STRIPE_SECRET.as_bytes(),
755            &[NOW.to_string().as_bytes(), b".", body],
756        ));
757        assert!(!stripe_verifies(
758            STRIPE_SECRET,
759            &format!("v1={digest}"),
760            body,
761            NOW
762        ));
763        assert!(!stripe_verifies(
764            STRIPE_SECRET,
765            &format!("t=recently,v1={digest}"),
766            body,
767            NOW
768        ));
769        assert!(!stripe_verifies(STRIPE_SECRET, "t=1800000000", body, NOW));
770        // The timestamp carries the replay tolerance here too.
771        let sent = NOW - DEFAULT_TOLERANCE_SECS - 1;
772        let stale = stripe_sign(STRIPE_SECRET, sent, body);
773        assert!(!stripe_verifies(STRIPE_SECRET, &stale, body, NOW));
774    }
775
776    #[test]
777    fn a_provider_scheme_verifies_raw_and_timestamped_deliveries() {
778        let body = br#"{"event":"order.created"}"#;
779        let raw = provider(Hex, None, None);
780        let got = headers(&[("X-Signature", &provider_sign(None, body))]);
781        // No timestamp header: the payload is the raw body and the
782        // tolerance does not apply — "now" is whatever the caller says.
783        assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW));
784        assert!(raw.verify(PROVIDER_SECRET, &got, body, NOW + 10_000));
785        assert!(!raw.verify(PROVIDER_SECRET, &got, b"x", NOW));
786        assert!(!raw.verify("other", &got, body, NOW));
787        assert!(
788            !raw.verify("", &got, body, NOW),
789            "an empty secret is not a key"
790        );
791        // With a timestamp header the payload is `{timestamp}.{body}`, a
792        // configured header that is not sent refuses instead of silently
793        // downgrading, and a prefixed entry is stripped before decoding.
794        let stamped = provider(Hex, Some("sha256="), Some("X-At"));
795        let header = format!("sha256={}", provider_sign(Some(NOW), body));
796        let with_at = headers(&[("X-Signature", &header), ("X-At", "1800000000")]);
797        assert!(stamped.verify(PROVIDER_SECRET, &with_at, body, NOW));
798        let no_at = headers(&[("X-Signature", &header)]);
799        assert!(!stamped.verify(PROVIDER_SECRET, &no_at, body, NOW));
800        let no_sig = headers(&[("X-At", "1800000000")]);
801        assert!(!stamped.verify(PROVIDER_SECRET, &no_sig, body, NOW));
802        // Base64 encoding works too; an entry that does not decode is
803        // skipped rather than fatal.
804        let b64 = provider(Base64, None, None);
805        let encoded = STANDARD.encode(mac(PROVIDER_SECRET.as_bytes(), &[body]));
806        let b64hdr = headers(&[("X-Signature", &encoded)]);
807        assert!(b64.verify(PROVIDER_SECRET, &b64hdr, body, NOW));
808        let junk = headers(&[("X-Signature", "!!!!")]);
809        assert!(!b64.verify(PROVIDER_SECRET, &junk, body, NOW));
810    }
811
812    /// GitHub's own example from "Validating webhook deliveries": secret
813    /// `It's a Secret to Everybody`, body `Hello, World!`, and the
814    /// `sha256=` header GitHub documents.
815    #[test]
816    fn a_github_delivery_verifies() {
817        const GITHUB_SECRET: &str = "It's a Secret to Everybody";
818        const GITHUB_BODY: &[u8] = b"Hello, World!";
819        const GITHUB_SIGNATURE: &str =
820            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
821        let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
822        let verifier = WebhookVerifier::new(Github);
823        // No timestamp rides the delivery, so the tolerance never applies:
824        // "now" is whatever the caller says.
825        assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW));
826        assert!(verifier.verify(GITHUB_SECRET, &got, GITHUB_BODY, NOW + 10_000));
827    }
828
829    #[test]
830    fn a_tampered_github_delivery_or_wrong_secret_refuses() {
831        const GITHUB_SECRET: &str = "It's a Secret to Everybody";
832        const GITHUB_BODY: &[u8] = b"Hello, World!";
833        const GITHUB_SIGNATURE: &str =
834            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
835        let verifier = WebhookVerifier::new(Github);
836        let got = headers(&[("X-Hub-Signature-256", GITHUB_SIGNATURE)]);
837        assert!(!verifier.verify(GITHUB_SECRET, &got, b"Hello, World?", NOW));
838        assert!(!verifier.verify("another-secret-dummy", &got, GITHUB_BODY, NOW));
839        assert!(
840            !verifier.verify("", &got, GITHUB_BODY, NOW),
841            "an empty secret is not a key"
842        );
843    }
844
845    #[test]
846    fn unreadable_github_input_refuses() {
847        const GITHUB_SECRET: &str = "It's a Secret to Everybody";
848        const GITHUB_BODY: &[u8] = b"Hello, World!";
849        const GITHUB_SIGNATURE: &str =
850            "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
851        let verifier = WebhookVerifier::new(Github);
852        // No header at all; the legacy sha1 header; a header with no
853        // `sha256=` prefix; and a prefix with a body that is not hex.
854        assert!(!verifier.verify(GITHUB_SECRET, &headers(&[]), GITHUB_BODY, NOW));
855        assert!(!verifier.verify(
856            GITHUB_SECRET,
857            &headers(&[("X-Hub-Signature", "sha1=deadbeef")]),
858            GITHUB_BODY,
859            NOW
860        ));
861        assert!(!verifier.verify(
862            GITHUB_SECRET,
863            &headers(&[("X-Hub-Signature-256", "deadbeef")]),
864            GITHUB_BODY,
865            NOW
866        ));
867        let unprefixed = GITHUB_SIGNATURE.strip_prefix("sha256=").expect("prefixed");
868        assert!(!verifier.verify(
869            GITHUB_SECRET,
870            &headers(&[("X-Hub-Signature-256", unprefixed)]),
871            GITHUB_BODY,
872            NOW
873        ));
874    }
875
876    /// Whether a Vercel delivery with this header value verifies.
877    fn vercel_verifies(secret: &str, signature: &str, body: &[u8], now: i64) -> bool {
878        let got = headers(&[("x-vercel-signature", signature)]);
879        WebhookVerifier::new(Vercel).verify(secret, &got, body, now)
880    }
881
882    #[test]
883    fn hmac_sha1_matches_the_rfc_2202_vector() {
884        // RFC 2202, HMAC-SHA1 test case 2 — the same value `openssl dgst
885        // -sha1 -hmac Jefe` prints — anchors the SHA-1 path independently
886        // of anything this crate computes.
887        let tag = hex(&mac_sha1(b"Jefe", &[b"what do ya want for nothing?"]));
888        assert_eq!(tag, "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79");
889    }
890
891    #[test]
892    fn a_vercel_delivery_verifies_and_tampered_ones_refuse() {
893        // The documented example: lowercase hex HMAC-SHA1 over a raw,
894        // Vercel-shaped deployment body.
895        let signature = hex(&mac_sha1(VERCEL_SECRET.as_bytes(), &[VERCEL_BODY]));
896        assert_eq!(signature, "d6543f159dfdf17c47130f1019ba14ed71bd4ca5");
897        assert!(vercel_verifies(VERCEL_SECRET, &signature, VERCEL_BODY, NOW));
898        // No timestamp, so "now" does not enter the answer.
899        assert!(vercel_verifies(
900            VERCEL_SECRET,
901            &signature,
902            VERCEL_BODY,
903            NOW + 10_000
904        ));
905        // One byte of the body changed; another endpoint's secret.
906        let tampered =
907            br#"{"type":"deployment.succeeded","payload":{"deployment":{"id":"dpl_2"}}}"#;
908        assert!(!vercel_verifies(VERCEL_SECRET, &signature, tampered, NOW));
909        assert!(!vercel_verifies(
910            "another-secret-dummy",
911            &signature,
912            VERCEL_BODY,
913            NOW
914        ));
915        // An empty secret is not a key.
916        assert!(!vercel_verifies("", &signature, VERCEL_BODY, NOW));
917    }
918
919    #[test]
920    fn unreadable_vercel_input_refuses() {
921        // Missing header entirely.
922        assert!(!WebhookVerifier::new(Vercel).verify(
923            VERCEL_SECRET,
924            &headers(&[]),
925            VERCEL_BODY,
926            NOW
927        ));
928        // An odd digit count, a non-hex pair, and an even but wrong length
929        // (not the 20 bytes of an HMAC-SHA1 tag) all refuse before the
930        // comparison — no truncated or padded value is ever compared.
931        assert!(!vercel_verifies(
932            VERCEL_SECRET,
933            &"a".repeat(39),
934            VERCEL_BODY,
935            NOW
936        ));
937        assert!(!vercel_verifies(
938            VERCEL_SECRET,
939            "not-hex!!",
940            VERCEL_BODY,
941            NOW
942        ));
943        assert!(!vercel_verifies(VERCEL_SECRET, "zzzz", VERCEL_BODY, NOW));
944        assert!(!vercel_verifies(VERCEL_SECRET, "abcd", VERCEL_BODY, NOW));
945        assert!(!vercel_verifies(
946            VERCEL_SECRET,
947            &"a".repeat(41),
948            VERCEL_BODY,
949            NOW
950        ));
951    }
952
953    /// Whether a GitLab delivery with this token header value verifies.
954    fn gitlab_verifies(secret: &str, token: &str, body: &[u8], now: i64) -> bool {
955        let got = headers(&[("X-Gitlab-Token", token)]);
956        WebhookVerifier::new(Gitlab).verify(secret, &got, body, now)
957    }
958
959    #[test]
960    fn a_gitlab_shared_token_verifies_and_ignores_the_body() {
961        let body = br#"{"object_kind":"push"}"#;
962        assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, body, NOW));
963        // The body plays no part: a different body, or none, still matches.
964        assert!(gitlab_verifies(GITLAB_SECRET, GITLAB_SECRET, b"", NOW));
965        assert!(gitlab_verifies(
966            GITLAB_SECRET,
967            GITLAB_SECRET,
968            b"a wholly different body",
969            NOW + 10_000
970        ));
971        // Header lookup is case-insensitive, as http::HeaderMap always is.
972        let lower = headers(&[("x-gitlab-token", GITLAB_SECRET)]);
973        assert!(WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &lower, body, NOW));
974    }
975
976    #[test]
977    fn wrong_or_unreadable_gitlab_tokens_refuse() {
978        let body = b"{}";
979        // The wrong token, and one of a different length — the comparison
980        // fails either way, and length is not secret.
981        assert!(!gitlab_verifies(
982            GITLAB_SECRET,
983            "other-token-dummy",
984            body,
985            NOW
986        ));
987        assert!(!gitlab_verifies(
988            GITLAB_SECRET,
989            "gitlab-shared-token-dummy-plus",
990            body,
991            NOW
992        ));
993        // Missing header; empty token; empty secret.
994        assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &headers(&[]), body, NOW));
995        assert!(!gitlab_verifies(GITLAB_SECRET, "", body, NOW));
996        assert!(!gitlab_verifies("", GITLAB_SECRET, body, NOW));
997        // A non-UTF-8 header value is unreadable, and unreadable refuses.
998        let mut raw = HeaderMap::new();
999        raw.insert(
1000            "X-Gitlab-Token",
1001            http::HeaderValue::from_bytes(&[0xff, 0xfe, 0x80]).expect("opaque header value"),
1002        );
1003        assert!(!WebhookVerifier::new(Gitlab).verify(GITLAB_SECRET, &raw, body, NOW));
1004        // The generic scheme honours its own header name.
1005        let custom = WebhookVerifier::new(SharedTokenScheme {
1006            header: "X-Custom-Token",
1007        });
1008        assert!(custom.verify(
1009            GITLAB_SECRET,
1010            &headers(&[("X-Custom-Token", GITLAB_SECRET)]),
1011            body,
1012            NOW
1013        ));
1014        assert!(!custom.verify(
1015            GITLAB_SECRET,
1016            &headers(&[("X-Gitlab-Token", GITLAB_SECRET)]),
1017            body,
1018            NOW
1019        ));
1020    }
1021}