1#![forbid(unsafe_code)]
2
3use 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
19pub const DOMAIN_WEBHOOK_FIXTURE: &str = "uselesskey:webhook:fixture";
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
24pub enum WebhookProfile {
25 GitHub,
27 Stripe,
29 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#[derive(Clone, Debug, Eq, PartialEq)]
45pub enum WebhookPayloadSpec {
46 Canonical,
48 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#[derive(Clone)]
67pub struct WebhookFixture {
68 pub profile: WebhookProfile,
70 pub secret: String,
72 pub payload: String,
74 pub headers: BTreeMap<String, String>,
76 pub timestamp: i64,
78 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#[derive(Clone)]
96pub struct NearMissWebhookFixture {
97 pub scenario: NearMissScenario,
99 pub profile: WebhookProfile,
101 pub secret: String,
103 pub payload: String,
105 pub headers: BTreeMap<String, String>,
107 pub timestamp: i64,
109 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub enum NearMissScenario {
129 StaleTimestamp,
131 WrongSecret,
133 TamperedPayload,
135}
136
137pub trait WebhookFactoryExt {
139 fn webhook(
141 &self,
142 profile: WebhookProfile,
143 label: impl AsRef<str>,
144 payload_spec: WebhookPayloadSpec,
145 ) -> WebhookFixture;
146
147 fn webhook_github(
149 &self,
150 label: impl AsRef<str>,
151 payload_spec: WebhookPayloadSpec,
152 ) -> WebhookFixture;
153
154 fn webhook_stripe(
156 &self,
157 label: impl AsRef<str>,
158 payload_spec: WebhookPayloadSpec,
159 ) -> WebhookFixture;
160
161 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 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 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 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 let repository = json_string(&format!("acme/{label}"));
319 format!(
320 "{{\"action\":\"opened\",\"repository\":{{\"full_name\":{repository}}},\"number\":{}}}",
321 (nonce % 9000) + 1000
322 )
323 }
324 WebhookProfile::Stripe => {
325 let label = json_string(label);
326 let mut payload = format!(
327 "{{\"id\":\"evt_{nonce:08x}\",\"type\":\"checkout.session.completed\",\"data\":{{\"object\":{{\"metadata\":{{\"label\":"
328 );
329 payload.push_str(&label);
330 payload.push_str("}}}}");
331 payload
332 }
333 WebhookProfile::Slack => {
334 let text = json_string(&format!("ping {label}"));
335 format!(
336 "{{\"type\":\"event_callback\",\"team_id\":\"T{nonce:08x}\",\"event\":{{\"type\":\"app_mention\",\"text\":{text}}}}}"
337 )
338 }
339 },
340 }
341}
342
343fn json_string(value: &str) -> String {
344 serde_json::to_string(value).expect("serializing a string to JSON cannot fail")
345}
346
347fn sign(
348 profile: WebhookProfile,
349 secret: &str,
350 payload: &str,
351 timestamp: i64,
352) -> (BTreeMap<String, String>, String) {
353 let mut headers = BTreeMap::new();
354 headers.insert("Content-Type".to_string(), "application/json".to_string());
355
356 match profile {
357 WebhookProfile::GitHub => {
358 let signature_input = payload.to_string();
359 let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
360 headers.insert(
361 "X-Hub-Signature-256".to_string(),
362 format!("sha256={digest}"),
363 );
364 (headers, signature_input)
365 }
366 WebhookProfile::Stripe => {
367 let signature_input = format!("{timestamp}.{payload}");
368 let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
369 headers.insert(
370 "Stripe-Signature".to_string(),
371 format!("t={timestamp},v1={digest}"),
372 );
373 (headers, signature_input)
374 }
375 WebhookProfile::Slack => {
376 let signature_input = format!("v0:{timestamp}:{payload}");
377 let digest = hmac_sha256_hex(secret.as_bytes(), signature_input.as_bytes());
378 headers.insert(
379 "X-Slack-Request-Timestamp".to_string(),
380 timestamp.to_string(),
381 );
382 headers.insert("X-Slack-Signature".to_string(), format!("v0={digest}"));
383 (headers, signature_input)
384 }
385 }
386}
387
388fn hmac_sha256_hex(secret: &[u8], msg: &[u8]) -> String {
389 let mut mac = hmac::Hmac::<Sha256>::new_from_slice(secret).expect("HMAC key is always valid");
390 mac.update(msg);
391 let out = mac.finalize().into_bytes();
392 hex::encode(out)
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use uselesskey_core::Seed;
399
400 fn verify_github(secret: &str, payload: &str, headers: &BTreeMap<String, String>) -> bool {
401 let expected = format!(
402 "sha256={}",
403 hmac_sha256_hex(secret.as_bytes(), payload.as_bytes())
404 );
405 headers.get("X-Hub-Signature-256") == Some(&expected)
406 }
407
408 fn verify_stripe(
409 secret: &str,
410 payload: &str,
411 headers: &BTreeMap<String, String>,
412 now: i64,
413 tolerance_secs: i64,
414 ) -> bool {
415 let Some(sig_header) = headers.get("Stripe-Signature") else {
416 return false;
417 };
418 let mut ts = None;
419 let mut v1 = None;
420 for part in sig_header.split(',') {
421 if let Some(v) = part.strip_prefix("t=") {
422 ts = v.parse::<i64>().ok();
423 }
424 if let Some(v) = part.strip_prefix("v1=") {
425 v1 = Some(v.to_string());
426 }
427 }
428 let Some(ts) = ts else {
429 return false;
430 };
431 if (now - ts).abs() > tolerance_secs {
432 return false;
433 }
434 let base = format!("{ts}.{payload}");
435 let expected = hmac_sha256_hex(secret.as_bytes(), base.as_bytes());
436 v1.as_deref() == Some(expected.as_str())
437 }
438
439 fn verify_slack(
440 secret: &str,
441 payload: &str,
442 headers: &BTreeMap<String, String>,
443 now: i64,
444 tolerance_secs: i64,
445 ) -> bool {
446 let Some(ts_str) = headers.get("X-Slack-Request-Timestamp") else {
447 return false;
448 };
449 let Ok(ts) = ts_str.parse::<i64>() else {
450 return false;
451 };
452 if (now - ts).abs() > tolerance_secs {
453 return false;
454 }
455 let Some(sig) = headers.get("X-Slack-Signature") else {
456 return false;
457 };
458 let base = format!("v0:{ts}:{payload}");
459 let expected = format!("v0={}", hmac_sha256_hex(secret.as_bytes(), base.as_bytes()));
460 sig == &expected
461 }
462
463 #[test]
464 fn deterministic_github_fixture_is_stable() {
465 let fx = Factory::deterministic(Seed::from_env_value("webhook-gh").unwrap());
466 let a = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
467 let b = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
468 assert_eq!(a.secret, b.secret);
469 assert_eq!(a.payload, b.payload);
470 assert_eq!(a.headers, b.headers);
471 assert!(verify_github(&a.secret, &a.payload, &a.headers));
472 }
473
474 #[test]
475 fn provider_signature_paths_verify() {
476 let fx = Factory::deterministic(Seed::from_env_value("webhook-providers").unwrap());
477 let gh = fx.webhook(WebhookProfile::GitHub, "a", WebhookPayloadSpec::Canonical);
478 let st = fx.webhook_stripe("b", WebhookPayloadSpec::Canonical);
479 let sl = fx.webhook_slack("c", WebhookPayloadSpec::Canonical);
480
481 assert!(verify_github(&gh.secret, &gh.payload, &gh.headers));
482 assert!(verify_stripe(
483 &st.secret,
484 &st.payload,
485 &st.headers,
486 st.timestamp,
487 300
488 ));
489 assert!(verify_slack(
490 &sl.secret,
491 &sl.payload,
492 &sl.headers,
493 sl.timestamp,
494 300
495 ));
496 }
497
498 #[test]
499 fn payload_spec_stable_bytes_are_shape_sensitive() {
500 assert_eq!(WebhookPayloadSpec::Canonical.stable_bytes(), b"canonical");
501 assert_eq!(
502 WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
503 b"raw:one"
504 );
505 assert_ne!(
506 WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
507 WebhookPayloadSpec::Raw("two".to_string()).stable_bytes()
508 );
509 assert_ne!(
510 stable_spec_bytes(WebhookProfile::GitHub, &WebhookPayloadSpec::Canonical),
511 stable_spec_bytes(WebhookProfile::Stripe, &WebhookPayloadSpec::Canonical)
512 );
513 }
514
515 #[test]
516 fn generated_timestamp_uses_expected_seeded_window() {
517 let seed = [7_u8; 32];
518 let mut rng = ChaCha20Rng::from_seed(seed);
519 let mut secret_bytes = [0_u8; 32];
520 rng.fill_bytes(&mut secret_bytes);
521 let expected = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
522
523 let fixture = build_fixture_from_seed(
524 WebhookProfile::Stripe,
525 "billing",
526 WebhookPayloadSpec::Canonical,
527 &seed,
528 );
529
530 assert_eq!(fixture.timestamp, expected);
531 assert!((1_700_000_000..1_900_000_000).contains(&fixture.timestamp));
532 }
533
534 #[test]
535 fn generated_secrets_match_provider_shapes() {
536 let mut rng = ChaCha20Rng::from_seed([9_u8; 32]);
537 let github = build_secret(WebhookProfile::GitHub, &mut rng);
538 let stripe = build_secret(WebhookProfile::Stripe, &mut rng);
539 let slack = build_secret(WebhookProfile::Slack, &mut rng);
540
541 assert_eq!(github.len(), "ghs_".len() + 43);
542 assert!(github.starts_with("ghs_"));
543 assert!(
544 github["ghs_".len()..]
545 .bytes()
546 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
547 );
548
549 assert_eq!(stripe.len(), "whsec_".len() + 64);
550 assert!(stripe.starts_with("whsec_"));
551 assert_lower_hex(&stripe["whsec_".len()..]);
552
553 assert_eq!(slack.len(), 64);
554 assert_lower_hex(&slack);
555 }
556
557 #[test]
558 fn header_shape_matches_provider_conventions() {
559 let fx = Factory::deterministic(Seed::from_env_value("webhook-headers").unwrap());
560 let gh = fx.webhook_github("r", WebhookPayloadSpec::Canonical);
561 assert!(
562 gh.headers
563 .get("X-Hub-Signature-256")
564 .is_some_and(|v| v.starts_with("sha256="))
565 );
566
567 let st = fx.webhook_stripe("r", WebhookPayloadSpec::Canonical);
568 let stripe_header = st.headers.get("Stripe-Signature").expect("stripe header");
569 assert!(stripe_header.contains("t="));
570 assert!(stripe_header.contains(",v1="));
571
572 let sl = fx.webhook_slack("r", WebhookPayloadSpec::Canonical);
573 assert!(sl.headers.contains_key("X-Slack-Request-Timestamp"));
574 assert!(
575 sl.headers
576 .get("X-Slack-Signature")
577 .is_some_and(|v| v.starts_with("v0="))
578 );
579 }
580
581 #[test]
582 fn near_miss_negatives_fail_provider_verification() {
583 let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss").unwrap());
584 let st = fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical);
585 let now = st.timestamp;
586
587 let stale = st.near_miss_stale_timestamp(300);
588 assert_eq!(stale.timestamp, st.timestamp - 301);
589 assert_eq!(
590 stale.signature_input,
591 format!("{}.{}", stale.timestamp, stale.payload)
592 );
593 assert!(!verify_stripe(
594 &st.secret,
595 &st.payload,
596 &stale.headers,
597 now,
598 300
599 ));
600
601 let wrong_secret = st.near_miss_wrong_secret();
602 assert!(!verify_stripe(
603 &st.secret,
604 &wrong_secret.payload,
605 &wrong_secret.headers,
606 wrong_secret.timestamp,
607 300
608 ));
609
610 let tampered = st.near_miss_tampered_payload();
611 assert!(!verify_stripe(
612 &tampered.secret,
613 &st.payload,
614 &tampered.headers,
615 tampered.timestamp,
616 300
617 ));
618 }
619
620 #[test]
621 fn debug_redacts_secret() {
622 let fx = Factory::random();
623 let fixture = fx.webhook_slack("debug", WebhookPayloadSpec::Canonical);
624 let out = format!("{fixture:?}");
625 assert!(!out.contains(&fixture.secret));
626 assert!(out.contains("WebhookFixture"));
627
628 let near_miss = fixture.near_miss_wrong_secret();
629 let out = format!("{near_miss:?}");
630 assert!(!out.contains(&near_miss.secret));
631 assert!(out.contains("NearMissWebhookFixture"));
632 }
633
634 #[test]
635 fn canonical_payload_escapes_special_characters_in_label() {
636 let fx = Factory::deterministic(Seed::from_env_value("webhook-label-escape").unwrap());
637 let label = "repo\"line\nbreak\\slash";
638 let fixtures = [
639 fx.webhook_github(label, WebhookPayloadSpec::Canonical),
640 fx.webhook_stripe(label, WebhookPayloadSpec::Canonical),
641 fx.webhook_slack(label, WebhookPayloadSpec::Canonical),
642 ];
643
644 for fixture in fixtures {
645 let parsed: serde_json::Value =
646 serde_json::from_str(&fixture.payload).expect("canonical payload should be valid");
647 let serialized = parsed.to_string();
648 assert!(
649 serialized.contains("repo\\\"line\\nbreak\\\\slash"),
650 "serialized payload should preserve escaped label, got: {serialized}"
651 );
652 }
653 }
654
655 #[test]
656 fn canonical_payload_preserves_plain_label_field_order() {
657 assert_eq!(
658 canonical_payload(
659 WebhookProfile::GitHub,
660 "repo",
661 WebhookPayloadSpec::Canonical,
662 12
663 ),
664 "{\"action\":\"opened\",\"repository\":{\"full_name\":\"acme/repo\"},\"number\":1012}"
665 );
666 assert_eq!(
667 canonical_payload(
668 WebhookProfile::Stripe,
669 "billing",
670 WebhookPayloadSpec::Canonical,
671 0x0f
672 ),
673 "{\"id\":\"evt_0000000f\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"metadata\":{\"label\":\"billing\"}}}}"
674 );
675 assert_eq!(
676 canonical_payload(
677 WebhookProfile::Slack,
678 "alerts",
679 WebhookPayloadSpec::Canonical,
680 0x10
681 ),
682 "{\"type\":\"event_callback\",\"team_id\":\"T00000010\",\"event\":{\"type\":\"app_mention\",\"text\":\"ping alerts\"}}"
683 );
684 }
685
686 fn assert_lower_hex(value: &str) {
687 assert!(
688 value
689 .bytes()
690 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
691 "expected lowercase hex: {value}"
692 );
693 }
694}