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}