Skip to main content

uselesskey_webhook/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Webhook fixtures built on `uselesskey-core`.
4//!
5//! This crate provides deterministic provider-style webhook fixtures with canonical
6//! payloads, signature input strings, and signed headers.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use base64::Engine as _;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use hmac::{KeyInit, Mac};
14use rand_chacha10::ChaCha20Rng;
15use rand_core10::{Rng, SeedableRng};
16use sha2::Sha256;
17use uselesskey_core::Factory;
18
19/// Cache domain for webhook fixtures.
20pub const DOMAIN_WEBHOOK_FIXTURE: &str = "uselesskey:webhook:fixture";
21
22/// Supported webhook signing profiles.
23#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum WebhookProfile {
25    /// GitHub webhook signature profile.
26    GitHub,
27    /// Stripe webhook signature profile.
28    Stripe,
29    /// Slack webhook signature profile.
30    Slack,
31}
32
33impl WebhookProfile {
34    fn stable_tag(self) -> &'static str {
35        match self {
36            Self::GitHub => "github",
37            Self::Stripe => "stripe",
38            Self::Slack => "slack",
39        }
40    }
41}
42
43/// Canonical payload presets for webhook fixtures.
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub enum WebhookPayloadSpec {
46    /// Use the built-in provider canonical payload template.
47    Canonical,
48    /// Use an explicit payload string.
49    Raw(String),
50}
51
52impl WebhookPayloadSpec {
53    fn stable_bytes(&self) -> Vec<u8> {
54        match self {
55            Self::Canonical => b"canonical".to_vec(),
56            Self::Raw(payload) => {
57                let mut out = b"raw:".to_vec();
58                out.extend_from_slice(payload.as_bytes());
59                out
60            }
61        }
62    }
63}
64
65/// A generated webhook fixture.
66#[derive(Clone)]
67pub struct WebhookFixture {
68    /// Profile used to generate fixture semantics.
69    pub profile: WebhookProfile,
70    /// Signing secret (test-only).
71    pub secret: String,
72    /// Canonical payload body.
73    pub payload: String,
74    /// HTTP headers to attach to the request.
75    pub headers: BTreeMap<String, String>,
76    /// Timestamp used in signature generation (unix epoch seconds).
77    pub timestamp: i64,
78    /// Canonical signature input/base string.
79    pub signature_input: String,
80}
81
82impl fmt::Debug for WebhookFixture {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.debug_struct("WebhookFixture")
85            .field("profile", &self.profile)
86            .field("payload", &self.payload)
87            .field("headers", &self.headers)
88            .field("timestamp", &self.timestamp)
89            .field("signature_input", &self.signature_input)
90            .finish_non_exhaustive()
91    }
92}
93
94/// A near-miss webhook fixture for negative tests.
95#[derive(Clone)]
96pub struct NearMissWebhookFixture {
97    /// Negative scenario marker.
98    pub scenario: NearMissScenario,
99    /// Profile used to generate fixture semantics.
100    pub profile: WebhookProfile,
101    /// Signing secret (intentionally wrong for `WrongSecret`).
102    pub secret: String,
103    /// Payload body (intentionally modified for `TamperedPayload`).
104    pub payload: String,
105    /// HTTP headers to attach to the request.
106    pub headers: BTreeMap<String, String>,
107    /// Timestamp used in signature generation (may be stale).
108    pub timestamp: i64,
109    /// Canonical signature input/base string.
110    pub signature_input: String,
111}
112
113impl fmt::Debug for NearMissWebhookFixture {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("NearMissWebhookFixture")
116            .field("scenario", &self.scenario)
117            .field("profile", &self.profile)
118            .field("payload", &self.payload)
119            .field("headers", &self.headers)
120            .field("timestamp", &self.timestamp)
121            .field("signature_input", &self.signature_input)
122            .finish_non_exhaustive()
123    }
124}
125
126/// Supported near-miss negative scenarios.
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum NearMissScenario {
129    /// Header timestamp falls outside the acceptable window.
130    StaleTimestamp,
131    /// Request signed with an alternate secret not used by verifier.
132    WrongSecret,
133    /// Payload differs from what was signed.
134    TamperedPayload,
135}
136
137/// Extension trait to generate webhook fixtures from [`Factory`].
138pub trait WebhookFactoryExt {
139    /// Generate a webhook fixture for an explicit profile.
140    fn webhook(
141        &self,
142        profile: WebhookProfile,
143        label: impl AsRef<str>,
144        payload_spec: WebhookPayloadSpec,
145    ) -> WebhookFixture;
146
147    /// Generate a GitHub webhook fixture.
148    fn webhook_github(
149        &self,
150        label: impl AsRef<str>,
151        payload_spec: WebhookPayloadSpec,
152    ) -> WebhookFixture;
153
154    /// Generate a Stripe webhook fixture.
155    fn webhook_stripe(
156        &self,
157        label: impl AsRef<str>,
158        payload_spec: WebhookPayloadSpec,
159    ) -> WebhookFixture;
160
161    /// Generate a Slack webhook fixture.
162    fn webhook_slack(
163        &self,
164        label: impl AsRef<str>,
165        payload_spec: WebhookPayloadSpec,
166    ) -> WebhookFixture;
167}
168
169impl WebhookFactoryExt for Factory {
170    fn webhook(
171        &self,
172        profile: WebhookProfile,
173        label: impl AsRef<str>,
174        payload_spec: WebhookPayloadSpec,
175    ) -> WebhookFixture {
176        let label = label.as_ref();
177        let spec_bytes = stable_spec_bytes(profile, &payload_spec);
178        let cached = self.get_or_init(DOMAIN_WEBHOOK_FIXTURE, label, &spec_bytes, "good", |seed| {
179            build_fixture_from_seed(profile, label, payload_spec.clone(), seed.bytes())
180        });
181        cached.as_ref().clone()
182    }
183
184    fn webhook_github(
185        &self,
186        label: impl AsRef<str>,
187        payload_spec: WebhookPayloadSpec,
188    ) -> WebhookFixture {
189        self.webhook(WebhookProfile::GitHub, label, payload_spec)
190    }
191
192    fn webhook_stripe(
193        &self,
194        label: impl AsRef<str>,
195        payload_spec: WebhookPayloadSpec,
196    ) -> WebhookFixture {
197        self.webhook(WebhookProfile::Stripe, label, payload_spec)
198    }
199
200    fn webhook_slack(
201        &self,
202        label: impl AsRef<str>,
203        payload_spec: WebhookPayloadSpec,
204    ) -> WebhookFixture {
205        self.webhook(WebhookProfile::Slack, label, payload_spec)
206    }
207}
208
209impl WebhookFixture {
210    /// Produce a stale-timestamp variant for replay-window tests.
211    pub fn near_miss_stale_timestamp(&self, max_age_secs: i64) -> NearMissWebhookFixture {
212        let stale_ts = self.timestamp - max_age_secs - 1;
213        let mut f = self.with_timestamp(stale_ts);
214        f.scenario = NearMissScenario::StaleTimestamp;
215        f
216    }
217
218    /// Produce a wrong-secret variant for verifier mismatch tests.
219    pub fn near_miss_wrong_secret(&self) -> NearMissWebhookFixture {
220        let mut wrong_secret = self.secret.clone();
221        wrong_secret.push_str("_wrong");
222        let mut f = build_near_miss(
223            self.profile,
224            wrong_secret,
225            self.payload.clone(),
226            self.timestamp,
227        );
228        f.scenario = NearMissScenario::WrongSecret;
229        f
230    }
231
232    /// Produce a tampered-payload variant for integrity tests.
233    pub fn near_miss_tampered_payload(&self) -> NearMissWebhookFixture {
234        let tampered = format!("{}{}", self.payload, "\n");
235        let mut f = build_near_miss(self.profile, self.secret.clone(), tampered, self.timestamp);
236        f.scenario = NearMissScenario::TamperedPayload;
237        f
238    }
239
240    fn with_timestamp(&self, timestamp: i64) -> NearMissWebhookFixture {
241        build_near_miss(
242            self.profile,
243            self.secret.clone(),
244            self.payload.clone(),
245            timestamp,
246        )
247    }
248}
249
250fn build_near_miss(
251    profile: WebhookProfile,
252    secret: String,
253    payload: String,
254    timestamp: i64,
255) -> NearMissWebhookFixture {
256    let (headers, signature_input) = sign(profile, &secret, &payload, timestamp);
257    NearMissWebhookFixture {
258        scenario: NearMissScenario::StaleTimestamp,
259        profile,
260        secret,
261        payload,
262        headers,
263        timestamp,
264        signature_input,
265    }
266}
267
268fn stable_spec_bytes(profile: WebhookProfile, payload_spec: &WebhookPayloadSpec) -> Vec<u8> {
269    let mut out = profile.stable_tag().as_bytes().to_vec();
270    out.push(0);
271    out.extend_from_slice(&payload_spec.stable_bytes());
272    out
273}
274
275fn build_fixture_from_seed(
276    profile: WebhookProfile,
277    label: &str,
278    payload_spec: WebhookPayloadSpec,
279    seed: &[u8; 32],
280) -> WebhookFixture {
281    let mut rng = ChaCha20Rng::from_seed(*seed);
282    let secret = build_secret(profile, &mut rng);
283    let timestamp = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
284    let payload = canonical_payload(profile, label, payload_spec, rng.next_u32());
285    let (headers, signature_input) = sign(profile, &secret, &payload, timestamp);
286
287    WebhookFixture {
288        profile,
289        secret,
290        payload,
291        headers,
292        timestamp,
293        signature_input,
294    }
295}
296
297fn build_secret(profile: WebhookProfile, rng: &mut ChaCha20Rng) -> String {
298    let mut secret_bytes = [0_u8; 32];
299    rng.fill_bytes(&mut secret_bytes);
300
301    match profile {
302        WebhookProfile::GitHub => format!("ghs_{}", URL_SAFE_NO_PAD.encode(secret_bytes)),
303        WebhookProfile::Stripe => format!("whsec_{}", hex::encode(secret_bytes)),
304        WebhookProfile::Slack => hex::encode(secret_bytes),
305    }
306}
307
308fn canonical_payload(
309    profile: WebhookProfile,
310    label: &str,
311    payload_spec: WebhookPayloadSpec,
312    nonce: u32,
313) -> String {
314    match payload_spec {
315        WebhookPayloadSpec::Raw(payload) => payload,
316        WebhookPayloadSpec::Canonical => match profile {
317            WebhookProfile::GitHub => {
318                format!(
319                    "{{\"action\":\"opened\",\"repository\":{{\"full_name\":\"acme/{label}\"}},\"number\":{}}}",
320                    (nonce % 9000) + 1000
321                )
322            }
323            WebhookProfile::Stripe => format!(
324                "{{\"id\":\"evt_{:08x}\",\"type\":\"checkout.session.completed\",\"data\":{{\"object\":{{\"metadata\":{{\"label\":\"{}\"}}}}}}}}",
325                nonce, label
326            ),
327            WebhookProfile::Slack => format!(
328                "{{\"type\":\"event_callback\",\"team_id\":\"T{:08x}\",\"event\":{{\"type\":\"app_mention\",\"text\":\"ping {}\"}}}}",
329                nonce, label
330            ),
331        },
332    }
333}
334
335fn sign(
336    profile: WebhookProfile,
337    secret: &str,
338    payload: &str,
339    timestamp: i64,
340) -> (BTreeMap<String, String>, String) {
341    let mut headers = BTreeMap::new();
342    headers.insert("Content-Type".to_string(), "application/json".to_string());
343
344    match profile {
345        WebhookProfile::GitHub => {
346            let signature_input = payload.to_string();
347            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
348            headers.insert(
349                "X-Hub-Signature-256".to_string(),
350                format!("sha256={digest}"),
351            );
352            (headers, signature_input)
353        }
354        WebhookProfile::Stripe => {
355            let signature_input = format!("{timestamp}.{payload}");
356            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
357            headers.insert(
358                "Stripe-Signature".to_string(),
359                format!("t={timestamp},v1={digest}"),
360            );
361            (headers, signature_input)
362        }
363        WebhookProfile::Slack => {
364            let signature_input = format!("v0:{timestamp}:{payload}");
365            let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
366            headers.insert(
367                "X-Slack-Request-Timestamp".to_string(),
368                timestamp.to_string(),
369            );
370            headers.insert("X-Slack-Signature".to_string(), format!("v0={digest}"));
371            (headers, signature_input)
372        }
373    }
374}
375
376fn hmac_sha256_hex(secret: &[u8], msg: &[u8]) -> String {
377    let mut mac = hmac::Hmac::<Sha256>::new_from_slice(secret).expect("HMAC key is always valid");
378    mac.update(msg);
379    let out = mac.finalize().into_bytes();
380    hex::encode(out)
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use uselesskey_core::Seed;
387
388    fn verify_github(secret: &str, payload: &str, headers: &BTreeMap<String, String>) -> bool {
389        let expected = format!(
390            "sha256={}",
391            hmac_sha256_hex(secret.as_bytes(), payload.as_bytes())
392        );
393        headers.get("X-Hub-Signature-256") == Some(&expected)
394    }
395
396    fn verify_stripe(
397        secret: &str,
398        payload: &str,
399        headers: &BTreeMap<String, String>,
400        now: i64,
401        tolerance_secs: i64,
402    ) -> bool {
403        let Some(sig_header) = headers.get("Stripe-Signature") else {
404            return false;
405        };
406        let mut ts = None;
407        let mut v1 = None;
408        for part in sig_header.split(',') {
409            if let Some(v) = part.strip_prefix("t=") {
410                ts = v.parse::<i64>().ok();
411            }
412            if let Some(v) = part.strip_prefix("v1=") {
413                v1 = Some(v.to_string());
414            }
415        }
416        let Some(ts) = ts else {
417            return false;
418        };
419        if (now - ts).abs() > tolerance_secs {
420            return false;
421        }
422        let base = format!("{ts}.{payload}");
423        let expected = hmac_sha256_hex(secret.as_bytes(), base.as_bytes());
424        v1.as_deref() == Some(expected.as_str())
425    }
426
427    fn verify_slack(
428        secret: &str,
429        payload: &str,
430        headers: &BTreeMap<String, String>,
431        now: i64,
432        tolerance_secs: i64,
433    ) -> bool {
434        let Some(ts_str) = headers.get("X-Slack-Request-Timestamp") else {
435            return false;
436        };
437        let Ok(ts) = ts_str.parse::<i64>() else {
438            return false;
439        };
440        if (now - ts).abs() > tolerance_secs {
441            return false;
442        }
443        let Some(sig) = headers.get("X-Slack-Signature") else {
444            return false;
445        };
446        let base = format!("v0:{ts}:{payload}");
447        let expected = format!("v0={}", hmac_sha256_hex(secret.as_bytes(), base.as_bytes()));
448        sig == &expected
449    }
450
451    #[test]
452    fn deterministic_github_fixture_is_stable() {
453        let fx = Factory::deterministic(Seed::from_env_value("webhook-gh").unwrap());
454        let a = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
455        let b = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
456        assert_eq!(a.secret, b.secret);
457        assert_eq!(a.payload, b.payload);
458        assert_eq!(a.headers, b.headers);
459        assert!(verify_github(&a.secret, &a.payload, &a.headers));
460    }
461
462    #[test]
463    fn provider_signature_paths_verify() {
464        let fx = Factory::deterministic(Seed::from_env_value("webhook-providers").unwrap());
465        let gh = fx.webhook(WebhookProfile::GitHub, "a", WebhookPayloadSpec::Canonical);
466        let st = fx.webhook_stripe("b", WebhookPayloadSpec::Canonical);
467        let sl = fx.webhook_slack("c", WebhookPayloadSpec::Canonical);
468
469        assert!(verify_github(&gh.secret, &gh.payload, &gh.headers));
470        assert!(verify_stripe(
471            &st.secret,
472            &st.payload,
473            &st.headers,
474            st.timestamp,
475            300
476        ));
477        assert!(verify_slack(
478            &sl.secret,
479            &sl.payload,
480            &sl.headers,
481            sl.timestamp,
482            300
483        ));
484    }
485
486    #[test]
487    fn header_shape_matches_provider_conventions() {
488        let fx = Factory::deterministic(Seed::from_env_value("webhook-headers").unwrap());
489        let gh = fx.webhook_github("r", WebhookPayloadSpec::Canonical);
490        assert!(
491            gh.headers
492                .get("X-Hub-Signature-256")
493                .is_some_and(|v| v.starts_with("sha256="))
494        );
495
496        let st = fx.webhook_stripe("r", WebhookPayloadSpec::Canonical);
497        let stripe_header = st.headers.get("Stripe-Signature").expect("stripe header");
498        assert!(stripe_header.contains("t="));
499        assert!(stripe_header.contains(",v1="));
500
501        let sl = fx.webhook_slack("r", WebhookPayloadSpec::Canonical);
502        assert!(sl.headers.contains_key("X-Slack-Request-Timestamp"));
503        assert!(
504            sl.headers
505                .get("X-Slack-Signature")
506                .is_some_and(|v| v.starts_with("v0="))
507        );
508    }
509
510    #[test]
511    fn near_miss_negatives_fail_provider_verification() {
512        let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss").unwrap());
513        let st = fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical);
514        let now = st.timestamp;
515
516        let stale = st.near_miss_stale_timestamp(300);
517        assert!(!verify_stripe(
518            &st.secret,
519            &st.payload,
520            &stale.headers,
521            now,
522            300
523        ));
524
525        let wrong_secret = st.near_miss_wrong_secret();
526        assert!(!verify_stripe(
527            &st.secret,
528            &wrong_secret.payload,
529            &wrong_secret.headers,
530            wrong_secret.timestamp,
531            300
532        ));
533
534        let tampered = st.near_miss_tampered_payload();
535        assert!(!verify_stripe(
536            &tampered.secret,
537            &st.payload,
538            &tampered.headers,
539            tampered.timestamp,
540            300
541        ));
542    }
543
544    #[test]
545    fn debug_redacts_secret() {
546        let fx = Factory::random();
547        let fixture = fx.webhook_slack("debug", WebhookPayloadSpec::Canonical);
548        let out = format!("{fixture:?}");
549        assert!(!out.contains(&fixture.secret));
550        assert!(out.contains("WebhookFixture"));
551
552        let near_miss = fixture.near_miss_wrong_secret();
553        let out = format!("{near_miss:?}");
554        assert!(!out.contains(&near_miss.secret));
555        assert!(out.contains("NearMissWebhookFixture"));
556    }
557}