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}