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
8mod fixture;
9mod model;
10mod payload;
11mod secret;
12mod signature;
13
14pub use fixture::WebhookFactoryExt;
15pub use model::{
16    NearMissScenario, NearMissWebhookFixture, WebhookFixture, WebhookPayloadSpec, WebhookProfile,
17};
18
19/// Cache domain for webhook fixtures.
20pub const DOMAIN_WEBHOOK_FIXTURE: &str = "uselesskey:webhook:fixture";
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25    use std::collections::BTreeMap;
26
27    use crate::fixture::build_fixture_from_seed;
28    use crate::payload::{canonical_payload, stable_spec_bytes};
29    use crate::secret::build_secret;
30    use crate::signature::hmac_sha256_hex;
31    use rand_chacha10::ChaCha20Rng;
32    use rand_core10::{Rng, SeedableRng};
33    use uselesskey_core::{Factory, Seed};
34
35    #[test]
36    fn hmac_sha256_matches_rfc4231_test_vector() {
37        let key = [0x0b_u8; 20];
38        let digest = hmac_sha256_hex(&key, b"Hi There");
39
40        assert_eq!(
41            digest,
42            "b0344c61d8db38535ca8afceaf0bf12b\
43             881dc200c9833da726e9376c2e32cff7"
44                .replace(char::is_whitespace, "")
45        );
46    }
47
48    #[test]
49    fn hmac_sha256_preserves_block_sized_key_without_hashing() {
50        let key = [0xaa_u8; 64];
51        let digest = hmac_sha256_hex(&key, b"block-size boundary");
52
53        assert_eq!(
54            digest,
55            "4bf714ba9df6b88605adb3e0a8a8b6d0320041fc2577408eaeb6e7120a03cf43"
56        );
57    }
58
59    fn verify_github(secret: &str, payload: &str, headers: &BTreeMap<String, String>) -> bool {
60        let expected = format!(
61            "sha256={}",
62            hmac_sha256_hex(secret.as_bytes(), payload.as_bytes())
63        );
64        headers.get("X-Hub-Signature-256") == Some(&expected)
65    }
66
67    fn verify_stripe(
68        secret: &str,
69        payload: &str,
70        headers: &BTreeMap<String, String>,
71        now: i64,
72        tolerance_secs: i64,
73    ) -> bool {
74        let Some(sig_header) = headers.get("Stripe-Signature") else {
75            return false;
76        };
77        let mut ts = None;
78        let mut v1 = None;
79        for part in sig_header.split(',') {
80            if let Some(v) = part.strip_prefix("t=") {
81                ts = v.parse::<i64>().ok();
82            }
83            if let Some(v) = part.strip_prefix("v1=") {
84                v1 = Some(v.to_string());
85            }
86        }
87        let Some(ts) = ts else {
88            return false;
89        };
90        if (now - ts).abs() > tolerance_secs {
91            return false;
92        }
93        let base = format!("{ts}.{payload}");
94        let expected = hmac_sha256_hex(secret.as_bytes(), base.as_bytes());
95        v1.as_deref() == Some(expected.as_str())
96    }
97
98    fn verify_slack(
99        secret: &str,
100        payload: &str,
101        headers: &BTreeMap<String, String>,
102        now: i64,
103        tolerance_secs: i64,
104    ) -> bool {
105        let Some(ts_str) = headers.get("X-Slack-Request-Timestamp") else {
106            return false;
107        };
108        let Ok(ts) = ts_str.parse::<i64>() else {
109            return false;
110        };
111        if (now - ts).abs() > tolerance_secs {
112            return false;
113        }
114        let Some(sig) = headers.get("X-Slack-Signature") else {
115            return false;
116        };
117        let base = format!("v0:{ts}:{payload}");
118        let expected = format!("v0={}", hmac_sha256_hex(secret.as_bytes(), base.as_bytes()));
119        sig == &expected
120    }
121
122    #[test]
123    fn deterministic_github_fixture_is_stable() {
124        let fx = Factory::deterministic(Seed::from_env_value("webhook-gh").unwrap());
125        let a = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
126        let b = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
127        assert_eq!(a.secret, b.secret);
128        assert_eq!(a.payload, b.payload);
129        assert_eq!(a.headers, b.headers);
130        assert!(verify_github(&a.secret, &a.payload, &a.headers));
131    }
132
133    #[test]
134    fn provider_signature_paths_verify() {
135        let fx = Factory::deterministic(Seed::from_env_value("webhook-providers").unwrap());
136        let gh = fx.webhook(WebhookProfile::GitHub, "a", WebhookPayloadSpec::Canonical);
137        let st = fx.webhook_stripe("b", WebhookPayloadSpec::Canonical);
138        let sl = fx.webhook_slack("c", WebhookPayloadSpec::Canonical);
139
140        assert!(verify_github(&gh.secret, &gh.payload, &gh.headers));
141        assert!(verify_stripe(
142            &st.secret,
143            &st.payload,
144            &st.headers,
145            st.timestamp,
146            300
147        ));
148        assert!(verify_slack(
149            &sl.secret,
150            &sl.payload,
151            &sl.headers,
152            sl.timestamp,
153            300
154        ));
155    }
156
157    #[test]
158    fn payload_spec_stable_bytes_are_shape_sensitive() {
159        assert_eq!(WebhookPayloadSpec::Canonical.stable_bytes(), b"canonical");
160        assert_eq!(
161            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
162            b"raw:one"
163        );
164        assert_ne!(
165            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
166            WebhookPayloadSpec::Raw("two".to_string()).stable_bytes()
167        );
168        assert_ne!(
169            stable_spec_bytes(WebhookProfile::GitHub, &WebhookPayloadSpec::Canonical),
170            stable_spec_bytes(WebhookProfile::Stripe, &WebhookPayloadSpec::Canonical)
171        );
172    }
173
174    #[test]
175    fn generated_timestamp_uses_expected_seeded_window() {
176        let seed = [7_u8; 32];
177        let mut rng = ChaCha20Rng::from_seed(seed);
178        let mut secret_bytes = [0_u8; 32];
179        rng.fill_bytes(&mut secret_bytes);
180        let expected = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
181
182        let fixture = build_fixture_from_seed(
183            WebhookProfile::Stripe,
184            "billing",
185            WebhookPayloadSpec::Canonical,
186            &seed,
187        );
188
189        assert_eq!(fixture.timestamp, expected);
190        assert!((1_700_000_000..1_900_000_000).contains(&fixture.timestamp));
191    }
192
193    #[test]
194    fn generated_secrets_match_provider_shapes() {
195        let mut rng = ChaCha20Rng::from_seed([9_u8; 32]);
196        let github = build_secret(WebhookProfile::GitHub, &mut rng);
197        let stripe = build_secret(WebhookProfile::Stripe, &mut rng);
198        let slack = build_secret(WebhookProfile::Slack, &mut rng);
199
200        assert_eq!(github.len(), "ghs_".len() + 43);
201        assert!(github.starts_with("ghs_"));
202        assert!(
203            github["ghs_".len()..]
204                .bytes()
205                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
206        );
207
208        assert_eq!(stripe.len(), "whsec_".len() + 64);
209        assert!(stripe.starts_with("whsec_"));
210        assert_lower_hex(&stripe["whsec_".len()..]);
211
212        assert_eq!(slack.len(), 64);
213        assert_lower_hex(&slack);
214    }
215
216    #[test]
217    fn header_shape_matches_provider_conventions() {
218        let fx = Factory::deterministic(Seed::from_env_value("webhook-headers").unwrap());
219        let gh = fx.webhook_github("r", WebhookPayloadSpec::Canonical);
220        assert!(
221            gh.headers
222                .get("X-Hub-Signature-256")
223                .is_some_and(|v| v.starts_with("sha256="))
224        );
225
226        let st = fx.webhook_stripe("r", WebhookPayloadSpec::Canonical);
227        let stripe_header = st.headers.get("Stripe-Signature").expect("stripe header");
228        assert!(stripe_header.contains("t="));
229        assert!(stripe_header.contains(",v1="));
230
231        let sl = fx.webhook_slack("r", WebhookPayloadSpec::Canonical);
232        assert!(sl.headers.contains_key("X-Slack-Request-Timestamp"));
233        assert!(
234            sl.headers
235                .get("X-Slack-Signature")
236                .is_some_and(|v| v.starts_with("v0="))
237        );
238    }
239
240    #[test]
241    fn near_miss_negatives_fail_provider_verification() {
242        let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss").unwrap());
243        let st = fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical);
244        let now = st.timestamp;
245
246        let stale = st.near_miss_stale_timestamp(300);
247        assert_eq!(stale.timestamp, st.timestamp - 301);
248        assert_eq!(
249            stale.signature_input,
250            format!("{}.{}", stale.timestamp, stale.payload)
251        );
252        assert!(!verify_stripe(
253            &st.secret,
254            &st.payload,
255            &stale.headers,
256            now,
257            300
258        ));
259
260        let wrong_secret = st.near_miss_wrong_secret();
261        assert!(!verify_stripe(
262            &st.secret,
263            &wrong_secret.payload,
264            &wrong_secret.headers,
265            wrong_secret.timestamp,
266            300
267        ));
268
269        let tampered = st.near_miss_tampered_payload();
270        assert!(!verify_stripe(
271            &tampered.secret,
272            &st.payload,
273            &tampered.headers,
274            tampered.timestamp,
275            300
276        ));
277    }
278
279    #[test]
280    fn debug_redacts_secret() {
281        let fx = Factory::random();
282        let fixture = fx.webhook_slack("debug", WebhookPayloadSpec::Canonical);
283        let out = format!("{fixture:?}");
284        assert!(!out.contains(&fixture.secret));
285        assert!(out.contains("WebhookFixture"));
286
287        let near_miss = fixture.near_miss_wrong_secret();
288        let out = format!("{near_miss:?}");
289        assert!(!out.contains(&near_miss.secret));
290        assert!(out.contains("NearMissWebhookFixture"));
291    }
292
293    #[test]
294    fn canonical_payload_escapes_special_characters_in_label() {
295        let fx = Factory::deterministic(Seed::from_env_value("webhook-label-escape").unwrap());
296        let label = "repo\"line\nbreak\\slash";
297        let fixtures = [
298            fx.webhook_github(label, WebhookPayloadSpec::Canonical),
299            fx.webhook_stripe(label, WebhookPayloadSpec::Canonical),
300            fx.webhook_slack(label, WebhookPayloadSpec::Canonical),
301        ];
302
303        for fixture in fixtures {
304            let parsed: serde_json::Value =
305                serde_json::from_str(&fixture.payload).expect("canonical payload should be valid");
306            let serialized = parsed.to_string();
307            assert!(
308                serialized.contains("repo\\\"line\\nbreak\\\\slash"),
309                "serialized payload should preserve escaped label, got: {serialized}"
310            );
311        }
312    }
313
314    #[test]
315    fn canonical_payload_preserves_plain_label_field_order() {
316        assert_eq!(
317            canonical_payload(
318                WebhookProfile::GitHub,
319                "repo",
320                WebhookPayloadSpec::Canonical,
321                12
322            ),
323            "{\"action\":\"opened\",\"repository\":{\"full_name\":\"acme/repo\"},\"number\":1012}"
324        );
325        assert_eq!(
326            canonical_payload(
327                WebhookProfile::Stripe,
328                "billing",
329                WebhookPayloadSpec::Canonical,
330                0x0f
331            ),
332            "{\"id\":\"evt_0000000f\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"metadata\":{\"label\":\"billing\"}}}}"
333        );
334        assert_eq!(
335            canonical_payload(
336                WebhookProfile::Slack,
337                "alerts",
338                WebhookPayloadSpec::Canonical,
339                0x10
340            ),
341            "{\"type\":\"event_callback\",\"team_id\":\"T00000010\",\"event\":{\"type\":\"app_mention\",\"text\":\"ping alerts\"}}"
342        );
343    }
344
345    fn assert_lower_hex(value: &str) {
346        assert!(
347            value
348                .bytes()
349                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
350            "expected lowercase hex: {value}"
351        );
352    }
353
354    #[test]
355    fn webhook_profile_stable_tag_is_unique_per_variant() {
356        use crate::model::WebhookProfile;
357        let tags = [
358            WebhookProfile::GitHub.stable_tag(),
359            WebhookProfile::Stripe.stable_tag(),
360            WebhookProfile::Slack.stable_tag(),
361        ];
362        assert_eq!(tags, ["github", "stripe", "slack"]);
363
364        let unique: std::collections::HashSet<&&str> = tags.iter().collect();
365        assert_eq!(unique.len(), tags.len(), "stable_tag values must be unique");
366    }
367
368    #[test]
369    fn raw_payload_spec_is_returned_verbatim() {
370        let fx = Factory::deterministic_from_str("webhook-raw-spec");
371        let payload = "{\"custom\":\"shape\"}".to_string();
372        let fixture = fx.webhook_github("raw-repo", WebhookPayloadSpec::Raw(payload.clone()));
373
374        assert_eq!(fixture.payload, payload);
375        assert!(verify_github(
376            &fixture.secret,
377            &fixture.payload,
378            &fixture.headers
379        ));
380    }
381
382    #[test]
383    fn raw_payload_distinct_strings_yield_distinct_cache_identities() {
384        let fx = Factory::deterministic_from_str("webhook-raw-cache");
385        let one = fx.webhook_stripe("svc", WebhookPayloadSpec::Raw("one".to_string()));
386        let two = fx.webhook_stripe("svc", WebhookPayloadSpec::Raw("two".to_string()));
387
388        assert_ne!(one.payload, two.payload);
389        assert_ne!(one.signature_input, two.signature_input);
390        assert_ne!(
391            one.headers.get("Stripe-Signature"),
392            two.headers.get("Stripe-Signature")
393        );
394    }
395
396    #[test]
397    fn stale_timestamp_near_miss_works_for_all_profiles() {
398        let fx = Factory::deterministic_from_str("webhook-stale-all");
399        let max_age = 300_i64;
400
401        for profile in [
402            WebhookProfile::GitHub,
403            WebhookProfile::Stripe,
404            WebhookProfile::Slack,
405        ] {
406            let base = fx.webhook(profile, "svc", WebhookPayloadSpec::Canonical);
407            let stale = base.near_miss_stale_timestamp(max_age);
408
409            assert_eq!(stale.scenario, NearMissScenario::StaleTimestamp);
410            assert_eq!(stale.timestamp, base.timestamp - max_age - 1);
411            // GitHub does not include a timestamp in its signed input, so the
412            // signature_input is just the payload; for Stripe/Slack the input
413            // includes the (stale) timestamp.
414            match profile {
415                WebhookProfile::GitHub => {
416                    assert_eq!(stale.signature_input, stale.payload);
417                }
418                WebhookProfile::Stripe => {
419                    assert_eq!(
420                        stale.signature_input,
421                        format!("{}.{}", stale.timestamp, stale.payload)
422                    );
423                }
424                WebhookProfile::Slack => {
425                    assert_eq!(
426                        stale.signature_input,
427                        format!("v0:{}:{}", stale.timestamp, stale.payload)
428                    );
429                }
430            }
431        }
432    }
433
434    #[test]
435    fn wrong_secret_near_miss_works_for_github_and_slack() {
436        let fx = Factory::deterministic_from_str("webhook-wrong-secret");
437
438        let gh = fx.webhook_github("svc", WebhookPayloadSpec::Canonical);
439        let gh_wrong = gh.near_miss_wrong_secret();
440        assert_eq!(gh_wrong.scenario, NearMissScenario::WrongSecret);
441        assert_ne!(gh_wrong.secret, gh.secret);
442        assert!(gh_wrong.secret.ends_with("_wrong"));
443        assert!(!verify_github(
444            &gh.secret,
445            &gh_wrong.payload,
446            &gh_wrong.headers
447        ));
448
449        let sl = fx.webhook_slack("svc", WebhookPayloadSpec::Canonical);
450        let sl_wrong = sl.near_miss_wrong_secret();
451        assert_eq!(sl_wrong.scenario, NearMissScenario::WrongSecret);
452        assert_ne!(sl_wrong.secret, sl.secret);
453        assert!(!verify_slack(
454            &sl.secret,
455            &sl_wrong.payload,
456            &sl_wrong.headers,
457            sl_wrong.timestamp,
458            300
459        ));
460    }
461
462    #[test]
463    fn tampered_payload_near_miss_works_for_github_and_slack() {
464        let fx = Factory::deterministic_from_str("webhook-tampered");
465
466        let gh = fx.webhook_github("svc", WebhookPayloadSpec::Canonical);
467        let gh_tampered = gh.near_miss_tampered_payload();
468        assert_eq!(gh_tampered.scenario, NearMissScenario::TamperedPayload);
469        assert_ne!(gh_tampered.payload, gh.payload);
470        // The tampered fixture re-signs its own modified payload, so it
471        // verifies against itself; verifying the *original* payload with the
472        // tampered signature must fail.
473        assert!(!verify_github(
474            &gh.secret,
475            &gh.payload,
476            &gh_tampered.headers
477        ));
478
479        let sl = fx.webhook_slack("svc", WebhookPayloadSpec::Canonical);
480        let sl_tampered = sl.near_miss_tampered_payload();
481        assert_eq!(sl_tampered.scenario, NearMissScenario::TamperedPayload);
482        assert_ne!(sl_tampered.payload, sl.payload);
483        assert!(!verify_slack(
484            &sl.secret,
485            &sl.payload,
486            &sl_tampered.headers,
487            sl_tampered.timestamp,
488            300
489        ));
490    }
491
492    #[test]
493    fn hmac_sha256_long_key_is_hashed_first() {
494        // RFC 4231 test vector 4: 131-byte key (longer than the 64-byte block),
495        // exercising the SHA-256 pre-hash branch of hmac_sha256_hex.
496        let key = vec![0xaa_u8; 131];
497        let digest = hmac_sha256_hex(
498            &key,
499            b"Test Using Larger Than Block-Size Key - Hash Key First",
500        );
501        assert_eq!(
502            digest,
503            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
504        );
505    }
506
507    #[test]
508    fn hmac_sha256_short_key_is_zero_padded() {
509        // A short key should be zero-padded into the 64-byte block, not hashed.
510        // This exercises the else-branch of hmac_sha256_hex with a sub-block-size key.
511        let short = b"key";
512        let padded = {
513            let mut padded = [0_u8; 64];
514            padded[..short.len()].copy_from_slice(short);
515            padded.to_vec()
516        };
517        assert_eq!(
518            hmac_sha256_hex(short, b"hello"),
519            hmac_sha256_hex(&padded, b"hello"),
520            "short key must zero-pad into the same block as the explicitly padded key"
521        );
522    }
523
524    #[test]
525    fn debug_redacts_secret_for_all_profiles() {
526        let fx = Factory::deterministic_from_str("webhook-debug-all");
527
528        for profile in [
529            WebhookProfile::GitHub,
530            WebhookProfile::Stripe,
531            WebhookProfile::Slack,
532        ] {
533            let fixture = fx.webhook(profile, "svc", WebhookPayloadSpec::Canonical);
534            let dbg = format!("{fixture:?}");
535            assert!(dbg.contains("WebhookFixture"));
536            assert!(
537                !dbg.contains(&fixture.secret),
538                "Debug for {profile:?} must not leak secret: {dbg}"
539            );
540        }
541    }
542
543    #[test]
544    fn debug_redacts_secret_for_all_near_miss_scenarios() {
545        let fx = Factory::deterministic_from_str("webhook-debug-nm");
546        let base = fx.webhook_stripe("svc", WebhookPayloadSpec::Canonical);
547
548        let scenarios = [
549            base.near_miss_stale_timestamp(300),
550            base.near_miss_wrong_secret(),
551            base.near_miss_tampered_payload(),
552        ];
553
554        for fixture in scenarios {
555            let dbg = format!("{fixture:?}");
556            assert!(dbg.contains("NearMissWebhookFixture"));
557            assert!(
558                !dbg.contains(&fixture.secret),
559                "Debug for {:?} must not leak secret: {dbg}",
560                fixture.scenario
561            );
562        }
563    }
564}