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 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}