use crate::error::OpenAIError;
pub struct Webhooks {
secret: Vec<u8>,
}
const TIMESTAMP_TOLERANCE_SECS: i64 = 300;
impl Webhooks {
pub fn new(secret: &str) -> Result<Self, OpenAIError> {
let raw = secret.strip_prefix("whsec_").unwrap_or(secret);
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, raw)
.map_err(|e| OpenAIError::InvalidArgument(format!("invalid webhook secret: {e}")))?;
Ok(Self { secret: decoded })
}
pub fn verify(
&self,
payload: &[u8],
signature_header: &str,
timestamp_header: &str,
) -> Result<(), OpenAIError> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let timestamp: i64 = timestamp_header
.parse()
.map_err(|_| OpenAIError::InvalidArgument("invalid webhook-timestamp header".into()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
if (now - timestamp).abs() > TIMESTAMP_TOLERANCE_SECS {
return Err(OpenAIError::InvalidArgument(format!(
"webhook timestamp too old or too new (delta={}s, tolerance={}s)",
(now - timestamp).abs(),
TIMESTAMP_TOLERANCE_SECS
)));
}
let signed_content = format!(
"{}.{}",
timestamp_header,
std::str::from_utf8(payload).unwrap_or("")
);
let mut mac = Hmac::<Sha256>::new_from_slice(&self.secret)
.map_err(|e| OpenAIError::InvalidArgument(format!("HMAC init failed: {e}")))?;
mac.update(signed_content.as_bytes());
let expected = mac.finalize().into_bytes();
let expected_b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &expected);
for sig in signature_header.split(' ') {
let parts: Vec<&str> = sig.splitn(2, ',').collect();
if parts.len() == 2 && parts[0] == "v1" && parts[1] == expected_b64 {
return Ok(());
}
}
Err(OpenAIError::InvalidArgument(
"webhook signature verification failed".into(),
))
}
pub fn unwrap<T: serde::de::DeserializeOwned>(
&self,
payload: &[u8],
signature_header: &str,
timestamp_header: &str,
) -> Result<T, OpenAIError> {
self.verify(payload, signature_header, timestamp_header)?;
serde_json::from_slice(payload).map_err(OpenAIError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_webhook_verify_valid() {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret_raw = b"test-secret-key-bytes!!";
let secret_b64 = base64::engine::general_purpose::STANDARD.encode(secret_raw);
let webhook_secret = format!("whsec_{secret_b64}");
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string();
let body = r#"{"type":"test","data":{}}"#;
let signed_content = format!("{timestamp}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret_raw).unwrap();
mac.update(signed_content.as_bytes());
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let sig_header = format!("v1,{sig}");
let wh = Webhooks::new(&webhook_secret).unwrap();
wh.verify(body.as_bytes(), &sig_header, ×tamp).unwrap();
}
#[test]
fn test_webhook_verify_invalid_signature() {
use base64::Engine;
let secret_raw = b"test-secret-key-bytes!!";
let secret_b64 = base64::engine::general_purpose::STANDARD.encode(secret_raw);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string();
let wh = Webhooks::new(&format!("whsec_{secret_b64}")).unwrap();
let result = wh.verify(b"body", "v1,invalidsignature", ×tamp);
assert!(result.is_err());
}
#[test]
fn test_webhook_verify_expired_timestamp() {
use base64::Engine;
let secret_raw = b"test-secret-key-bytes!!";
let secret_b64 = base64::engine::general_purpose::STANDARD.encode(secret_raw);
let old_timestamp = "1000000000";
let wh = Webhooks::new(&format!("whsec_{secret_b64}")).unwrap();
let result = wh.verify(b"body", "v1,sig", old_timestamp);
assert!(result.is_err());
assert!(format!("{result:?}").contains("too old"));
}
#[test]
fn test_webhook_unwrap() {
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
let secret_raw = b"unwrap-test-secret!!!!!";
let secret_b64 = base64::engine::general_purpose::STANDARD.encode(secret_raw);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string();
let body = r#"{"value":42}"#;
let signed_content = format!("{timestamp}.{body}");
let mut mac = Hmac::<Sha256>::new_from_slice(secret_raw).unwrap();
mac.update(signed_content.as_bytes());
let sig = base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes());
let wh = Webhooks::new(&format!("whsec_{secret_b64}")).unwrap();
let parsed: serde_json::Value = wh
.unwrap(body.as_bytes(), &format!("v1,{sig}"), ×tamp)
.unwrap();
assert_eq!(parsed["value"], 42);
}
}