rocket_webhook/webhooks/built_in/
standard.rs

1use base64::{Engine, prelude::BASE64_STANDARD};
2use hmac::Hmac;
3use rocket::{Request, data::Outcome, http::Status, outcome::try_outcome, tokio::io::AsyncRead};
4use sha2::Sha256;
5use zeroize::Zeroizing;
6
7use crate::{
8    WebhookError,
9    webhooks::{Webhook, interface::hmac::WebhookHmac},
10};
11
12const ID_HEADER: &str = "id";
13const TIMESTAMP_HEADER: &str = "timestamp";
14const SIG_HEADER: &str = "signature";
15
16/// # Standard Webhook
17/// **Standard Webhooks** spec used by Svix, Resend, Clerk, and others.
18///
19/// Looks for headers `webhook-id`, `webhook-timestamp`, `webhook-signature`. (`webhook-` prefix can
20/// be configured)
21///
22/// Signature is base64 HMAC of `<id>.<timestamp>.<body>`
23///
24/// ## Links
25/// - [Standard Webhooks spec](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md)
26/// - [Svix docs](https://docs.svix.com/receiving/verifying-payloads/how-manual)
27pub struct StandardWebhook {
28    secret_key: Zeroizing<Vec<u8>>,
29    id_header: String,
30    time_header: String,
31    sig_header: String,
32}
33
34impl StandardWebhook {
35    /// Instantiate using the secret key starting with `whsec_`. Assumes headers have a prefix
36    /// of `webhook-`.
37    pub fn with_secret(secret_key: impl AsRef<str>) -> Result<Self, base64::DecodeError> {
38        let stripped_key = secret_key
39            .as_ref()
40            .strip_prefix("whsec_")
41            .unwrap_or(secret_key.as_ref());
42        let secret_key = Zeroizing::new(BASE64_STANDARD.decode(stripped_key)?);
43        Ok(Self {
44            secret_key,
45            id_header: format!("webhook-{ID_HEADER}"),
46            sig_header: format!("webhook-{SIG_HEADER}"),
47            time_header: format!("webhook-{TIMESTAMP_HEADER}"),
48        })
49    }
50
51    /// Instantiate using the secret key starting with `whsec_` and a header prefix (include the
52    /// dash when providing the header prefix, e.g. `svix-`).
53    pub fn with_secret_and_prefix(
54        secret_key: impl AsRef<str>,
55        header_prefix: impl AsRef<str>,
56    ) -> Result<Self, base64::DecodeError> {
57        let stripped_key = secret_key
58            .as_ref()
59            .strip_prefix("whsec_")
60            .unwrap_or(secret_key.as_ref());
61        let secret_key = Zeroizing::new(BASE64_STANDARD.decode(stripped_key)?);
62        Ok(Self {
63            secret_key,
64            id_header: format!("{}{ID_HEADER}", header_prefix.as_ref()),
65            sig_header: format!("{}{SIG_HEADER}", header_prefix.as_ref()),
66            time_header: format!("{}{TIMESTAMP_HEADER}", header_prefix.as_ref()),
67        })
68    }
69}
70
71impl Webhook for StandardWebhook {
72    async fn validate_body(
73        &self,
74        req: &Request<'_>,
75        body: impl AsyncRead + Unpin + Send + Sync,
76        time_bounds: (u32, u32),
77    ) -> Outcome<'_, Vec<u8>, WebhookError> {
78        self.validate_with_hmac(req, body, time_bounds).await
79    }
80}
81
82impl WebhookHmac for StandardWebhook {
83    type MAC = Hmac<Sha256>;
84
85    fn secret_key(&self) -> &[u8] {
86        &self.secret_key
87    }
88
89    fn body_prefix(
90        &self,
91        req: &Request<'_>,
92        time_bounds: (u32, u32),
93    ) -> Outcome<'_, Option<Vec<u8>>, WebhookError> {
94        let id = try_outcome!(self.get_header(req, &self.id_header, None));
95        let timestamp = try_outcome!(self.get_header(req, &self.time_header, None));
96        try_outcome!(self.validate_timestamp(timestamp, time_bounds));
97
98        let prefix = [id.as_bytes(), b".", timestamp.as_bytes(), b"."].concat();
99        Outcome::Success(Some(prefix))
100    }
101
102    /// Multiple space delimited signatures in header, prefixed by `v1,`
103    fn expected_signatures(&self, req: &Request<'_>) -> Outcome<'_, Vec<Vec<u8>>, WebhookError> {
104        let header = try_outcome!(self.get_header(req, &self.sig_header, None));
105        let mut signatures = Vec::new();
106        for base64_sig in header.split(' ').filter_map(|s| s.strip_prefix("v1,")) {
107            match BASE64_STANDARD.decode(base64_sig) {
108                Ok(bytes) => signatures.push(bytes),
109                Err(_) => {
110                    return Outcome::Error((
111                        Status::BadRequest,
112                        WebhookError::InvalidHeader(format!(
113                            "Signature in '{}' header was not valid base64: got '{base64_sig}'",
114                            self.sig_header
115                        )),
116                    ));
117                }
118            }
119        }
120
121        Outcome::Success(signatures)
122    }
123}