Skip to main content

forge_core/webhook/
signature.rs

1use std::time::Duration;
2
3/// Configuration for webhook signature validation.
4#[derive(Debug, Clone)]
5pub struct SignatureConfig {
6    /// Algorithm used for signature verification.
7    pub algorithm: SignatureAlgorithm,
8    /// Header name containing the signature.
9    pub header_name: &'static str,
10    /// Environment variable name containing the secret.
11    pub secret_env: &'static str,
12}
13
14/// Supported signature algorithms.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum SignatureAlgorithm {
17    /// HMAC-SHA256 (e.g., GitHub)
18    HmacSha256,
19    /// HMAC-SHA1 (legacy, e.g., older GitHub)
20    HmacSha1,
21    /// HMAC-SHA512
22    HmacSha512,
23    /// Standard Webhooks (https://www.standardwebhooks.com) — used by Polar, Svix, Clerk, and others.
24    ///
25    /// Signs `{webhook-id}\n{webhook-timestamp}\n{body}` with HMAC-SHA256.
26    /// Signature header is always `webhook-signature` with format `v1,<base64>`.
27    /// Secret prefixes `whsec_` and `polar_whs_` are stripped before base64 decoding.
28    StandardWebhooks,
29    /// Stripe webhook format.
30    ///
31    /// Signs `{timestamp}.{body}` with HMAC-SHA256. Timestamp and signatures are
32    /// extracted from the `Stripe-Signature` header (`t=...,v1=...`). Requests older
33    /// than 5 minutes are rejected to prevent replay attacks.
34    StripeWebhooks,
35    /// HMAC-SHA256 with base64-encoded output (e.g., Shopify).
36    ///
37    /// Same algorithm as `HmacSha256` but the signature is base64-encoded instead of hex.
38    HmacSha256Base64,
39    /// Ed25519 asymmetric signature verification.
40    ///
41    /// The `secret_env` holds a base64-encoded Ed25519 public key (32 bytes).
42    /// Used by services that sign with a private key and distribute a public key for verification.
43    Ed25519,
44}
45
46impl SignatureAlgorithm {
47    /// Get the algorithm prefix used in signatures (e.g., "sha256=").
48    ///
49    /// Returns an empty string for algorithms that don't use a simple prefix.
50    pub fn prefix(&self) -> &'static str {
51        match self {
52            Self::HmacSha256 => "sha256=",
53            Self::HmacSha1 => "sha1=",
54            Self::HmacSha512 => "sha512=",
55            Self::StandardWebhooks => "v1,",
56            Self::StripeWebhooks | Self::HmacSha256Base64 | Self::Ed25519 => "",
57        }
58    }
59}
60
61/// Source for extracting idempotency key.
62#[derive(Debug, Clone)]
63pub enum IdempotencySource {
64    /// Extract from a header (e.g., "X-Request-Id").
65    Header(&'static str),
66    /// Extract from request body using JSONPath (e.g., "$.id").
67    Body(&'static str),
68}
69
70impl IdempotencySource {
71    /// Parse from attribute string (e.g., "header:X-Request-Id" or "body:$.id").
72    ///
73    /// NOTE: This uses `Box::leak` to produce `&'static str` references, so it
74    /// must only be called at startup/registration time (e.g., from proc macros),
75    /// never in request-handling hot paths. Calling this in a loop will leak memory.
76    pub fn parse(s: &str) -> Option<Self> {
77        let (prefix, value) = s.split_once(':')?;
78        // SAFETY: This is intended to be called only at startup during webhook registration.
79        // The leaked strings live for the program's lifetime, matching webhook configurations.
80        match prefix {
81            "header" => Some(Self::Header(Box::leak(value.to_string().into_boxed_str()))),
82            "body" => Some(Self::Body(Box::leak(value.to_string().into_boxed_str()))),
83            _ => None,
84        }
85    }
86}
87
88/// Configuration for webhook idempotency.
89#[derive(Debug, Clone)]
90pub struct IdempotencyConfig {
91    /// Source for the idempotency key.
92    pub source: IdempotencySource,
93    /// TTL for idempotency records (default: 24 hours).
94    pub ttl: Duration,
95}
96
97impl IdempotencyConfig {
98    /// Create a new idempotency config with default TTL.
99    pub fn new(source: IdempotencySource) -> Self {
100        Self {
101            source,
102            ttl: Duration::from_secs(24 * 60 * 60), // 24 hours
103        }
104    }
105
106    /// Set a custom TTL.
107    pub fn with_ttl(mut self, ttl: Duration) -> Self {
108        self.ttl = ttl;
109        self
110    }
111}
112
113/// Helper for constructing signature configurations.
114///
115/// Use in webhook attributes like:
116/// ```ignore
117/// #[forge::webhook(
118///     signature = WebhookSignature::hmac_sha256("X-Stripe-Signature", "STRIPE_SECRET"),
119/// )]
120/// ```
121pub struct WebhookSignature;
122
123impl WebhookSignature {
124    /// Create HMAC-SHA256 signature config.
125    ///
126    /// # Arguments
127    /// * `header` - The HTTP header containing the signature (e.g., "X-Hub-Signature-256")
128    /// * `secret_env` - Environment variable containing the secret
129    pub const fn hmac_sha256(header: &'static str, secret_env: &'static str) -> SignatureConfig {
130        SignatureConfig {
131            algorithm: SignatureAlgorithm::HmacSha256,
132            header_name: header,
133            secret_env,
134        }
135    }
136
137    /// Create HMAC-SHA1 signature config.
138    ///
139    /// # Arguments
140    /// * `header` - The HTTP header containing the signature
141    /// * `secret_env` - Environment variable containing the secret
142    pub const fn hmac_sha1(header: &'static str, secret_env: &'static str) -> SignatureConfig {
143        SignatureConfig {
144            algorithm: SignatureAlgorithm::HmacSha1,
145            header_name: header,
146            secret_env,
147        }
148    }
149
150    /// Create HMAC-SHA512 signature config.
151    ///
152    /// # Arguments
153    /// * `header` - The HTTP header containing the signature
154    /// * `secret_env` - Environment variable containing the secret
155    pub const fn hmac_sha512(header: &'static str, secret_env: &'static str) -> SignatureConfig {
156        SignatureConfig {
157            algorithm: SignatureAlgorithm::HmacSha512,
158            header_name: header,
159            secret_env,
160        }
161    }
162
163    /// Create a Standard Webhooks signature config (https://www.standardwebhooks.com).
164    ///
165    /// Used by Polar and other services that implement the Standard Webhooks spec.
166    /// The signature header is always `webhook-signature`; no need to specify it.
167    ///
168    /// The secret may have a `whsec_` or `polar_whs_` prefix — both are stripped
169    /// and the remainder is base64-decoded to obtain the raw HMAC key.
170    ///
171    /// # Arguments
172    /// * `secret_env` - Environment variable containing the webhook secret
173    pub const fn standard_webhooks(secret_env: &'static str) -> SignatureConfig {
174        SignatureConfig {
175            algorithm: SignatureAlgorithm::StandardWebhooks,
176            header_name: "webhook-signature",
177            secret_env,
178        }
179    }
180
181    /// Create a Stripe webhook signature config.
182    ///
183    /// Signs `{timestamp}.{body}` with HMAC-SHA256. The `Stripe-Signature` header
184    /// carries both the timestamp (`t=`) and one or more signatures (`v1=`). Requests
185    /// older than 5 minutes are rejected to guard against replay attacks.
186    ///
187    /// # Arguments
188    /// * `secret_env` - Environment variable containing the Stripe webhook signing secret
189    pub const fn stripe_webhooks(secret_env: &'static str) -> SignatureConfig {
190        SignatureConfig {
191            algorithm: SignatureAlgorithm::StripeWebhooks,
192            header_name: "stripe-signature",
193            secret_env,
194        }
195    }
196
197    /// Create a Shopify webhook signature config.
198    ///
199    /// HMAC-SHA256 over the raw body, base64-encoded. The signature arrives in the
200    /// `X-Shopify-Hmac-Sha256` header.
201    ///
202    /// # Arguments
203    /// * `secret_env` - Environment variable containing the Shopify webhook secret
204    pub const fn shopify_webhooks(secret_env: &'static str) -> SignatureConfig {
205        SignatureConfig {
206            algorithm: SignatureAlgorithm::HmacSha256Base64,
207            header_name: "x-shopify-hmac-sha256",
208            secret_env,
209        }
210    }
211
212    /// Create an Ed25519 asymmetric signature config.
213    ///
214    /// The service signs the request body with an Ed25519 private key and publishes
215    /// the matching public key for you to verify with. The `public_key_env` variable
216    /// should hold the base64-encoded 32-byte Ed25519 public key.
217    ///
218    /// # Arguments
219    /// * `header` - The HTTP header containing the base64-encoded signature
220    /// * `public_key_env` - Environment variable containing the base64-encoded public key
221    pub const fn ed25519(header: &'static str, public_key_env: &'static str) -> SignatureConfig {
222        SignatureConfig {
223            algorithm: SignatureAlgorithm::Ed25519,
224            header_name: header,
225            secret_env: public_key_env,
226        }
227    }
228}
229
230#[cfg(test)]
231#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_signature_config_creation() {
237        let config = WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_SECRET");
238        assert_eq!(config.algorithm, SignatureAlgorithm::HmacSha256);
239        assert_eq!(config.header_name, "X-Hub-Signature-256");
240        assert_eq!(config.secret_env, "GITHUB_SECRET");
241    }
242
243    #[test]
244    fn test_algorithm_prefix() {
245        assert_eq!(SignatureAlgorithm::HmacSha256.prefix(), "sha256=");
246        assert_eq!(SignatureAlgorithm::HmacSha1.prefix(), "sha1=");
247        assert_eq!(SignatureAlgorithm::HmacSha512.prefix(), "sha512=");
248    }
249
250    #[test]
251    fn test_idempotency_source_parsing() {
252        let header = IdempotencySource::parse("header:X-Request-Id");
253        assert!(matches!(
254            header,
255            Some(IdempotencySource::Header("X-Request-Id"))
256        ));
257
258        let body = IdempotencySource::parse("body:$.id");
259        assert!(matches!(body, Some(IdempotencySource::Body("$.id"))));
260
261        let invalid = IdempotencySource::parse("invalid");
262        assert!(invalid.is_none());
263    }
264
265    #[test]
266    fn test_idempotency_config_default_ttl() {
267        let config = IdempotencyConfig::new(IdempotencySource::Header("X-Id"));
268        assert_eq!(config.ttl, Duration::from_secs(24 * 60 * 60));
269    }
270}