rocket_webhook/webhooks/interface/
hmac.rs

1//! Interface for webhooks that use HMAC signature validation
2
3use hmac::{Mac, digest::KeyInit};
4use rocket::{
5    Request, data::Outcome, futures::StreamExt, http::Status, outcome::try_outcome,
6    tokio::io::AsyncRead,
7};
8use subtle::ConstantTimeEq;
9use tokio_util::io::ReaderStream;
10
11use crate::{
12    WebhookError,
13    webhooks::{Webhook, utils::body_size},
14};
15
16/// Trait for webhooks that use HMAC signature validation.
17pub trait WebhookHmac: Webhook {
18    /// MAC algorithm (from the `hmac` crate) used to calculate the signature
19    type MAC: Mac + KeyInit + Send;
20
21    /// Get the secret key used to sign the webhook
22    fn secret_key(&self) -> &[u8];
23
24    /// Get the expected signature(s) from the request. To obtain required headers,
25    /// you can use the `self.get_header()` utility.
26    fn expected_signatures(&self, req: &Request<'_>) -> Outcome<'_, Vec<Vec<u8>>, WebhookError>;
27
28    /// Get an optional prefix to attach to the raw body when calculating the signature. Timestamps
29    /// should be validated against the given bounds.
30    #[allow(unused_variables)]
31    fn body_prefix(
32        &self,
33        req: &Request<'_>,
34        time_bounds: (u32, u32),
35    ) -> Outcome<'_, Option<Vec<u8>>, WebhookError> {
36        Outcome::Success(None)
37    }
38
39    /// Get an optional suffix to attach to the raw body when calculating the signature. Timestamps
40    /// should be validated against the given bounds.
41    #[allow(unused_variables)]
42    fn body_suffix(
43        &self,
44        req: &Request<'_>,
45        time_bounds: (u32, u32),
46    ) -> Outcome<'_, Option<Vec<u8>>, WebhookError> {
47        Outcome::Success(None)
48    }
49
50    /// Read the request body and verify the HMAC signature. Calculates the HMAC
51    /// directly from the raw streamed body (with a prefix if configured).
52    fn validate_with_hmac(
53        &self,
54        req: &Request<'_>,
55        body: impl AsyncRead + Unpin + Send + Sync,
56        time_bounds: (u32, u32),
57    ) -> impl Future<Output = Outcome<'_, Vec<u8>, WebhookError>> + Send + Sync
58    where
59        Self: Sync,
60        Self::MAC: Sync,
61    {
62        async move {
63            // Get expected signatures from request
64            let expected_signatures = try_outcome!(self.expected_signatures(req));
65
66            // Get secret key and initialize HMAC
67            let key = self.secret_key();
68            let mut mac = <<Self as WebhookHmac>::MAC as hmac::Mac>::new_from_slice(key)
69                .expect("HMAC should take any key length");
70
71            // Update HMAC with prefix if there is one
72            if let Some(prefix) = try_outcome!(self.body_prefix(req, time_bounds)) {
73                mac.update(&prefix);
74            }
75
76            // Read body stream while calculating HMAC
77            let mut body_stream = ReaderStream::new(body);
78            let mut raw_body = Vec::with_capacity(body_size(req.headers()).unwrap_or(512));
79            while let Some(chunk_result) = body_stream.next().await {
80                match chunk_result {
81                    Ok(chunk_bytes) => {
82                        mac.update(&chunk_bytes);
83                        raw_body.extend_from_slice(&chunk_bytes);
84                    }
85                    Err(e) => {
86                        return Outcome::Error((Status::BadRequest, WebhookError::Read(e)));
87                    }
88                }
89            }
90
91            // Update HMAC with suffix if there is one
92            if let Some(suffix) = try_outcome!(self.body_suffix(req, time_bounds)) {
93                mac.update(&suffix);
94            }
95
96            // Check HMAC against all provided signatures
97            let body_sig = mac.finalize().into_bytes();
98            for signature in expected_signatures {
99                if body_sig.ct_eq(&signature).into() {
100                    return Outcome::Success(raw_body);
101                }
102            }
103            return Outcome::Error((
104                Status::Unauthorized,
105                WebhookError::Signature("HMAC didn't match any provided signature".into()),
106            ));
107        }
108    }
109}