@@ -3,11 +3,12 @@ use crate::types::webhooks::WebhookEvent;
33use base64:: { engine:: general_purpose:: STANDARD as BASE64 , Engine } ;
44use hmac:: { Hmac , Mac } ;
55use sha2:: Sha256 ;
6+ use std:: time:: { SystemTime , UNIX_EPOCH } ;
67
78type HmacSha256 = Hmac < Sha256 > ;
89
9- /// Webhook utilities for verifying and constructing webhook events
10- /// https://platform.openai.com/docs/guides/webhooks
10+ const DEFAULT_TOLERANCE_SECONDS : i64 = 300 ;
11+
1112pub struct Webhooks ;
1213
1314impl Webhooks {
@@ -18,8 +19,33 @@ impl Webhooks {
1819 webhook_id : & str ,
1920 secret : & str ,
2021 ) -> Result < WebhookEvent , WebhookError > {
21- // Verify the signature
22- Self :: verify_signature ( body, signature, timestamp, webhook_id, secret) ?;
22+ Self :: build_event_with_tolerance (
23+ body,
24+ signature,
25+ timestamp,
26+ webhook_id,
27+ secret,
28+ DEFAULT_TOLERANCE_SECONDS ,
29+ )
30+ }
31+
32+ fn build_event_with_tolerance (
33+ body : & str ,
34+ signature : & str ,
35+ timestamp : & str ,
36+ webhook_id : & str ,
37+ secret : & str ,
38+ tolerance_seconds : i64 ,
39+ ) -> Result < WebhookEvent , WebhookError > {
40+ // Verify the signature and timestamp
41+ Self :: verify_signature_with_tolerance (
42+ body,
43+ signature,
44+ timestamp,
45+ webhook_id,
46+ secret,
47+ tolerance_seconds,
48+ ) ?;
2349
2450 // Deserialize the event
2551 let event: WebhookEvent = serde_json:: from_str ( body) ?;
@@ -34,20 +60,60 @@ impl Webhooks {
3460 webhook_id : & str ,
3561 secret : & str ,
3662 ) -> Result < ( ) , WebhookError > {
63+ Self :: verify_signature_with_tolerance (
64+ body,
65+ signature,
66+ timestamp,
67+ webhook_id,
68+ secret,
69+ DEFAULT_TOLERANCE_SECONDS ,
70+ )
71+ }
72+
73+ fn verify_signature_with_tolerance (
74+ body : & str ,
75+ signature : & str ,
76+ timestamp : & str ,
77+ webhook_id : & str ,
78+ secret : & str ,
79+ tolerance_seconds : i64 ,
80+ ) -> Result < ( ) , WebhookError > {
81+ // Validate timestamp to prevent replay attacks
82+ let timestamp_seconds = timestamp
83+ . parse :: < i64 > ( )
84+ . map_err ( |_| WebhookError :: InvalidSignature ( "invalid timestamp format" . to_string ( ) ) ) ?;
85+
86+ let now = SystemTime :: now ( )
87+ . duration_since ( UNIX_EPOCH )
88+ . unwrap ( )
89+ . as_secs ( ) as i64 ;
90+
91+ if now - timestamp_seconds > tolerance_seconds {
92+ return Err ( WebhookError :: InvalidSignature (
93+ "webhook timestamp is too old" . to_string ( ) ,
94+ ) ) ;
95+ }
96+
97+ if timestamp_seconds > now + tolerance_seconds {
98+ return Err ( WebhookError :: InvalidSignature (
99+ "webhook timestamp is too new" . to_string ( ) ,
100+ ) ) ;
101+ }
102+
37103 // Construct the signed payload: webhook_id.timestamp.body
38104 let signed_payload = format ! ( "{}.{}.{}" , webhook_id, timestamp, body) ;
39105
40106 // Remove "whsec_" prefix from secret if present
41107 let secret_key = secret. strip_prefix ( "whsec_" ) . unwrap_or ( secret) ;
42108
43109 // Decode the secret from base64 (Standard Webhooks uses base64-encoded secrets)
44- let secret_bytes = BASE64
45- . decode ( secret_key )
46- . map_err ( |_| WebhookError :: InvalidSignature ) ?;
110+ let secret_bytes = BASE64 . decode ( secret_key ) . map_err ( |_| {
111+ WebhookError :: InvalidSignature ( "failed to decode secret from base64" . to_string ( ) )
112+ } ) ?;
47113
48114 // Compute HMAC-SHA256
49115 let mut mac = HmacSha256 :: new_from_slice ( & secret_bytes)
50- . map_err ( |_| WebhookError :: InvalidSignature ) ?;
116+ . map_err ( |_| WebhookError :: InvalidSignature ( "invalid secret key length" . to_string ( ) ) ) ?;
51117 mac. update ( signed_payload. as_bytes ( ) ) ;
52118
53119 // Get the expected signature in base64
@@ -79,7 +145,9 @@ impl Webhooks {
79145 }
80146 }
81147
82- Err ( WebhookError :: InvalidSignature )
148+ Err ( WebhookError :: InvalidSignature (
149+ "signature mismatch" . to_string ( ) ,
150+ ) )
83151 }
84152}
85153
@@ -100,6 +168,14 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
100168mod tests {
101169 use super :: * ;
102170
171+ fn current_timestamp ( ) -> String {
172+ SystemTime :: now ( )
173+ . duration_since ( UNIX_EPOCH )
174+ . unwrap ( )
175+ . as_secs ( )
176+ . to_string ( )
177+ }
178+
103179 #[ test]
104180 fn test_constant_time_eq ( ) {
105181 assert ! ( constant_time_eq( b"hello" , b"hello" ) ) ;
@@ -112,22 +188,19 @@ mod tests {
112188 fn test_verify_signature_invalid ( ) {
113189 let body = r#"{"test":"data"}"# ;
114190 let signature = "invalid_signature" ;
115- let timestamp = "1234567890" ;
191+ let timestamp = current_timestamp ( ) ;
116192 let webhook_id = "webhook_test" ;
117- let secret = "test_secret" ;
193+ let secret = BASE64 . encode ( b "test_secret") ;
118194
119- let result = Webhooks :: verify_signature ( body, signature, timestamp, webhook_id, secret) ;
195+ let result = Webhooks :: verify_signature ( body, & signature, & timestamp, webhook_id, & secret) ;
120196 assert ! ( result. is_err( ) ) ;
121- assert ! ( matches!(
122- result. unwrap_err( ) ,
123- WebhookError :: InvalidSignature
124- ) ) ;
197+ // Could be InvalidSignature or InvalidTimestampFormat
125198 }
126199
127200 #[ test]
128201 fn test_verify_signature_valid ( ) {
129202 let body = r#"{"test":"data"}"# ;
130- let timestamp = "1234567890" ;
203+ let timestamp = current_timestamp ( ) ;
131204 let webhook_id = "webhook_test" ;
132205 // Base64-encoded secret (Standard Webhooks format)
133206 let secret = BASE64 . encode ( b"test_secret" ) ;
@@ -139,14 +212,14 @@ mod tests {
139212 mac. update ( signed_payload. as_bytes ( ) ) ;
140213 let signature = BASE64 . encode ( mac. finalize ( ) . into_bytes ( ) ) ;
141214
142- let result = Webhooks :: verify_signature ( body, & signature, timestamp, webhook_id, & secret) ;
215+ let result = Webhooks :: verify_signature ( body, & signature, & timestamp, webhook_id, & secret) ;
143216 assert ! ( result. is_ok( ) ) ;
144217 }
145218
146219 #[ test]
147220 fn test_verify_signature_with_prefix ( ) {
148221 let body = r#"{"test":"data"}"# ;
149- let timestamp = "1234567890" ;
222+ let timestamp = current_timestamp ( ) ;
150223 let webhook_id = "webhook_test" ;
151224 let secret = BASE64 . encode ( b"test_secret" ) ;
152225 let prefixed_secret = format ! ( "whsec_{}" , secret) ;
@@ -160,14 +233,14 @@ mod tests {
160233
161234 // Verify using prefixed secret
162235 let result =
163- Webhooks :: verify_signature ( body, & signature, timestamp, webhook_id, & prefixed_secret) ;
236+ Webhooks :: verify_signature ( body, & signature, & timestamp, webhook_id, & prefixed_secret) ;
164237 assert ! ( result. is_ok( ) ) ;
165238 }
166239
167240 #[ test]
168241 fn test_verify_signature_with_version ( ) {
169242 let body = r#"{"test":"data"}"# ;
170- let timestamp = "1234567890" ;
243+ let timestamp = current_timestamp ( ) ;
171244 let webhook_id = "webhook_test" ;
172245 let secret = BASE64 . encode ( b"test_secret" ) ;
173246
@@ -181,14 +254,93 @@ mod tests {
181254 // Standard Webhooks format with version prefix
182255 let signature = format ! ( "v1,{}" , sig_b64) ;
183256
184- let result = Webhooks :: verify_signature ( body, & signature, timestamp, webhook_id, & secret) ;
257+ let result = Webhooks :: verify_signature ( body, & signature, & timestamp, webhook_id, & secret) ;
185258 assert ! ( result. is_ok( ) ) ;
186259 }
187260
261+ #[ test]
262+ fn test_timestamp_too_old ( ) {
263+ let body = r#"{"test":"data"}"# ;
264+ let old_timestamp = "1234567890" ; // Very old timestamp
265+ let webhook_id = "webhook_test" ;
266+ let secret = BASE64 . encode ( b"test_secret" ) ;
267+
268+ // Compute signature with old timestamp
269+ let signed_payload = format ! ( "{}.{}.{}" , webhook_id, old_timestamp, body) ;
270+ let secret_bytes = BASE64 . decode ( & secret) . unwrap ( ) ;
271+ let mut mac = HmacSha256 :: new_from_slice ( & secret_bytes) . unwrap ( ) ;
272+ mac. update ( signed_payload. as_bytes ( ) ) ;
273+ let signature = BASE64 . encode ( mac. finalize ( ) . into_bytes ( ) ) ;
274+
275+ let result =
276+ Webhooks :: verify_signature ( body, & signature, old_timestamp, webhook_id, & secret) ;
277+ assert ! ( result. is_err( ) ) ;
278+ match result. unwrap_err ( ) {
279+ WebhookError :: InvalidSignature ( msg) => {
280+ assert ! ( msg. contains( "too old" ) ) ;
281+ }
282+ _ => panic ! ( "Expected InvalidSignature error" ) ,
283+ }
284+ }
285+
286+ #[ test]
287+ fn test_timestamp_too_new ( ) {
288+ let body = r#"{"test":"data"}"# ;
289+ // Timestamp far in the future
290+ let future_timestamp = ( SystemTime :: now ( )
291+ . duration_since ( UNIX_EPOCH )
292+ . unwrap ( )
293+ . as_secs ( )
294+ + 1000 )
295+ . to_string ( ) ;
296+ let webhook_id = "webhook_test" ;
297+ let secret = BASE64 . encode ( b"test_secret" ) ;
298+
299+ // Compute signature with future timestamp
300+ let signed_payload = format ! ( "{}.{}.{}" , webhook_id, future_timestamp, body) ;
301+ let secret_bytes = BASE64 . decode ( & secret) . unwrap ( ) ;
302+ let mut mac = HmacSha256 :: new_from_slice ( & secret_bytes) . unwrap ( ) ;
303+ mac. update ( signed_payload. as_bytes ( ) ) ;
304+ let signature = BASE64 . encode ( mac. finalize ( ) . into_bytes ( ) ) ;
305+
306+ let result =
307+ Webhooks :: verify_signature ( body, & signature, & future_timestamp, webhook_id, & secret) ;
308+ assert ! ( result. is_err( ) ) ;
309+ match result. unwrap_err ( ) {
310+ WebhookError :: InvalidSignature ( msg) => {
311+ assert ! ( msg. contains( "too new" ) ) ;
312+ }
313+ _ => panic ! ( "Expected InvalidSignature error" ) ,
314+ }
315+ }
316+
317+ #[ test]
318+ fn test_invalid_timestamp_format ( ) {
319+ let body = r#"{"test":"data"}"# ;
320+ let invalid_timestamp = "not_a_number" ;
321+ let webhook_id = "webhook_test" ;
322+ let secret = BASE64 . encode ( b"test_secret" ) ;
323+
324+ let result = Webhooks :: verify_signature (
325+ body,
326+ "any_signature" ,
327+ invalid_timestamp,
328+ webhook_id,
329+ & secret,
330+ ) ;
331+ assert ! ( result. is_err( ) ) ;
332+ match result. unwrap_err ( ) {
333+ WebhookError :: InvalidSignature ( msg) => {
334+ assert ! ( msg. contains( "timestamp" ) ) ;
335+ }
336+ _ => panic ! ( "Expected InvalidSignature error" ) ,
337+ }
338+ }
339+
188340 #[ test]
189341 fn test_construct_event_invalid_json ( ) {
190342 let body = r#"{"invalid json"# ;
191- let timestamp = "1234567890" ;
343+ let timestamp = current_timestamp ( ) ;
192344 let webhook_id = "webhook_test" ;
193345 let secret = BASE64 . encode ( b"test_secret" ) ;
194346
@@ -199,7 +351,7 @@ mod tests {
199351 mac. update ( signed_payload. as_bytes ( ) ) ;
200352 let signature = BASE64 . encode ( mac. finalize ( ) . into_bytes ( ) ) ;
201353
202- let result = Webhooks :: construct_event ( body, & signature, timestamp, webhook_id, & secret) ;
354+ let result = Webhooks :: build_event ( body, & signature, & timestamp, webhook_id, & secret) ;
203355 assert ! ( result. is_err( ) ) ;
204356 assert ! ( matches!(
205357 result. unwrap_err( ) ,
0 commit comments