rocket_webhook/webhooks/generic/
hmac.rs

1use bon::Builder;
2use hmac::Hmac;
3use rocket::{Request, data::Outcome, http::Status, tokio::io::AsyncRead};
4use sha2::Sha256;
5use zeroize::Zeroizing;
6
7use crate::{
8    WebhookError,
9    webhooks::{Webhook, interface::hmac::WebhookHmac},
10};
11
12/**
13A custom webhook builder using HMAC SHA256 verification of the request body.
14
15# Example
16This sets up a webhook that expects a hex-encoded signature in the `Signature-SHA256` header, and a
17Unix epoch timestamp in the `Timestamp` header that will be attached as a suffix to the body
18when calculating the signature:
19
20```
21use rocket_webhook::{WebhookError, webhooks::generic::Hmac256Webhook};
22
23let my_webhook = Hmac256Webhook::builder()
24    .secret("my-secret")
25    .expected_signatures(|req| {
26        req.headers()
27            .get_one("Signature-SHA256")
28            .and_then(|header| hex::decode(header).ok())
29            .map(|header| vec![header])
30    })
31    .body_suffix(|req, (min_time, max_time)| {
32        req.headers()
33            .get_one("Timestamp")
34            .filter(|time| time.parse::<u32>().is_ok_and(|t| t > min_time && t < max_time))
35            .map(|time| time.as_bytes().to_vec())
36            .ok_or_else(|| WebhookError::Timestamp("Missing/invalid Timestamp header".into()))
37    })
38    .build();
39```
40*/
41#[derive(Builder)]
42pub struct Hmac256Webhook {
43    /// The secret used to sign the webhook. If the key is encoded in hex or base64, etc., it
44    /// must be decoded to bytes first
45    #[builder(with = |secret: impl Into<Vec<u8>>| Zeroizing::new(secret.into()))]
46    secret: Zeroizing<Vec<u8>>,
47    /// Function to get the expected, decoded signature(s) from the request (typically
48    /// derived from one of the request headers).
49    /// If `None` is returned, signature is presumed to be missing or invalid.
50    expected_signatures: fn(req: &Request<'_>) -> Option<Vec<Vec<u8>>>,
51    /// Function to get the prefix to attach to the body when calculating the signature. For replay
52    /// prevention, any timestamp should be validated against the given time bounds (in Unix epoch seconds).
53    body_prefix:
54        Option<fn(req: &Request<'_>, time_bounds: (u32, u32)) -> Result<Vec<u8>, WebhookError>>,
55    /// Function to get the suffix to attach to the body when calculating the signature. For replay
56    /// prevention, any timestamp should be validated against the given time bounds (in Unix epoch seconds).
57    body_suffix:
58        Option<fn(req: &Request<'_>, time_bounds: (u32, u32)) -> Result<Vec<u8>, WebhookError>>,
59}
60
61impl Webhook for Hmac256Webhook {
62    async fn validate_body(
63        &self,
64        req: &Request<'_>,
65        body: impl AsyncRead + Unpin + Send + Sync,
66        time_bounds: (u32, u32),
67    ) -> Outcome<'_, Vec<u8>, WebhookError> {
68        self.validate_with_hmac(req, body, time_bounds).await
69    }
70}
71
72impl WebhookHmac for Hmac256Webhook {
73    type MAC = Hmac<Sha256>;
74
75    fn secret_key(&self) -> &[u8] {
76        &self.secret
77    }
78
79    fn expected_signatures(&self, req: &Request<'_>) -> Outcome<'_, Vec<Vec<u8>>, WebhookError> {
80        match (self.expected_signatures)(req) {
81            Some(signatures) => Outcome::Success(signatures),
82            None => Outcome::Error((
83                Status::BadRequest,
84                WebhookError::Signature("Valid signature(s) not provided in request".into()),
85            )),
86        }
87    }
88
89    fn body_prefix(
90        &self,
91        req: &Request<'_>,
92        time_bounds: (u32, u32),
93    ) -> Outcome<'_, Option<Vec<u8>>, WebhookError> {
94        if let Some(prefix_fn) = self.body_prefix {
95            match (prefix_fn)(req, time_bounds) {
96                Ok(prefix) => Outcome::Success(Some(prefix)),
97                Err(err) => Outcome::Error((Status::BadRequest, err)),
98            }
99        } else {
100            Outcome::Success(None)
101        }
102    }
103
104    fn body_suffix(
105        &self,
106        req: &Request<'_>,
107        time_bounds: (u32, u32),
108    ) -> Outcome<'_, Option<Vec<u8>>, WebhookError> {
109        if let Some(suffix_fn) = self.body_suffix {
110            match (suffix_fn)(req, time_bounds) {
111                Ok(suffix) => Outcome::Success(Some(suffix)),
112                Err(err) => Outcome::Error((Status::BadRequest, err)),
113            }
114        } else {
115            Outcome::Success(None)
116        }
117    }
118}