1#![forbid(unsafe_code)]
2
3mod 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
19pub 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 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 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 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 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}