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}