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}