Skip to content

Commit 3fe2547

Browse files
committed
updates
1 parent acfea26 commit 3fe2547

File tree

2 files changed

+179
-27
lines changed

2 files changed

+179
-27
lines changed

async-openai/src/error.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ pub enum OpenAIError {
3131
/// Errors that can occur when processing webhooks
3232
#[derive(Debug, thiserror::Error)]
3333
pub enum WebhookError {
34-
/// Invalid webhook signature - verification failed
35-
#[error("invalid webhook signature")]
36-
InvalidSignature,
34+
/// Invalid webhook signature or signature verification failed
35+
#[error("invalid webhook signature: {0}")]
36+
InvalidSignature(String),
3737
/// Failed to deserialize webhook payload
3838
#[error("failed to deserialize webhook payload: {0}")]
3939
Deserialization(#[from] serde_json::Error),

async-openai/src/webhooks.rs

Lines changed: 176 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ use crate::types::webhooks::WebhookEvent;
33
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
44
use hmac::{Hmac, Mac};
55
use sha2::Sha256;
6+
use std::time::{SystemTime, UNIX_EPOCH};
67

78
type 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+
1112
pub struct Webhooks;
1213

1314
impl 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 {
100168
mod 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

Comments
 (0)