forge_core/webhook/signature.rs
1use std::time::Duration;
2
3/// Default replay window for non-Stripe webhook signature schemes (5 minutes).
4/// Stripe enforces its own 5-minute window via the `t=` field in its header
5/// and ignores this value.
6pub const DEFAULT_REPLAY_WINDOW_SECS: u64 = 300;
7
8/// Header used by non-Stripe schemes to convey the request's unix-seconds
9/// timestamp. Senders that don't ship this header are rejected when the
10/// scheme requires replay protection.
11pub const REPLAY_TIMESTAMP_HEADER: &str = "x-webhook-timestamp";
12
13/// Configuration for webhook signature validation.
14#[derive(Debug, Clone)]
15pub struct SignatureConfig {
16 /// Algorithm used for signature verification.
17 pub algorithm: SignatureAlgorithm,
18 /// Header name containing the signature.
19 pub header_name: &'static str,
20 /// Environment variable name containing the secret.
21 pub secret_env: &'static str,
22 /// Maximum age, in seconds, that a request may have before it is rejected
23 /// as a replay. For non-Stripe schemes the runtime reads
24 /// `x-webhook-timestamp` and compares to `now`. A value of `0` disables
25 /// replay enforcement (not recommended). Stripe always uses its own
26 /// 300s window from the `t=` field and ignores this setting.
27 pub replay_window_secs: u64,
28}
29
30/// Supported signature algorithms.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum SignatureAlgorithm {
34 /// HMAC-SHA256 (e.g., GitHub)
35 HmacSha256,
36 /// Stripe webhook format.
37 ///
38 /// Signs `{timestamp}.{body}` with HMAC-SHA256. Timestamp and signatures are
39 /// extracted from the `Stripe-Signature` header (`t=...,v1=...`). Requests older
40 /// than 5 minutes are rejected to prevent replay attacks.
41 StripeWebhooks,
42 /// HMAC-SHA256 with base64-encoded output (e.g., Shopify).
43 ///
44 /// Same algorithm as `HmacSha256` but the signature is base64-encoded instead of hex.
45 HmacSha256Base64,
46 /// Ed25519 asymmetric signature verification.
47 ///
48 /// The `secret_env` holds a base64-encoded Ed25519 public key (32 bytes).
49 /// Used by services that sign with a private key and distribute a public key for verification.
50 Ed25519,
51}
52
53impl SignatureAlgorithm {
54 /// Get the algorithm prefix used in signatures (e.g., "sha256=").
55 ///
56 /// Returns an empty string for algorithms that don't use a simple prefix.
57 pub fn prefix(&self) -> &'static str {
58 match self {
59 Self::HmacSha256 => "sha256=",
60 Self::StripeWebhooks | Self::HmacSha256Base64 | Self::Ed25519 => "",
61 }
62 }
63}
64
65/// Source for extracting idempotency key.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub enum IdempotencySource {
69 /// Extract from a header (e.g., "X-Request-Id").
70 Header(&'static str),
71 /// Extract from request body using JSONPath (e.g., "$.id").
72 Body(&'static str),
73}
74
75/// Configuration for webhook idempotency.
76#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub struct IdempotencyConfig {
79 /// Source for the idempotency key.
80 pub source: IdempotencySource,
81 /// TTL for idempotency records (default: 24 hours).
82 pub ttl: Duration,
83 /// Maximum time a webhook handler can run before the claim is
84 /// considered stale and eligible for reclaim. Protects against
85 /// process crashes leaving keys permanently locked.
86 /// Defaults to 5 minutes.
87 pub processing_timeout: Duration,
88}
89
90impl IdempotencyConfig {
91 /// Create a new idempotency config with default TTL.
92 pub fn new(source: IdempotencySource) -> Self {
93 Self {
94 source,
95 ttl: Duration::from_secs(24 * 60 * 60),
96 processing_timeout: Duration::from_secs(5 * 60),
97 }
98 }
99
100 /// Set a custom TTL.
101 pub fn with_ttl(mut self, ttl: Duration) -> Self {
102 self.ttl = ttl;
103 self
104 }
105
106 /// Set a custom processing timeout for crash recovery.
107 pub fn with_processing_timeout(mut self, timeout: Duration) -> Self {
108 self.processing_timeout = timeout;
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 replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
135 }
136 }
137
138 /// Create a Stripe webhook signature config.
139 ///
140 /// Signs `{timestamp}.{body}` with HMAC-SHA256. The `Stripe-Signature` header
141 /// carries both the timestamp (`t=`) and one or more signatures (`v1=`). Requests
142 /// older than 5 minutes are rejected to guard against replay attacks.
143 ///
144 /// # Arguments
145 /// * `secret_env` - Environment variable containing the Stripe webhook signing secret
146 pub const fn stripe_webhooks(secret_env: &'static str) -> SignatureConfig {
147 SignatureConfig {
148 algorithm: SignatureAlgorithm::StripeWebhooks,
149 header_name: "stripe-signature",
150 secret_env,
151 replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
152 }
153 }
154
155 /// Create a Shopify webhook signature config.
156 ///
157 /// HMAC-SHA256 over the raw body, base64-encoded. The signature arrives in the
158 /// `X-Shopify-Hmac-Sha256` header.
159 ///
160 /// # Arguments
161 /// * `secret_env` - Environment variable containing the Shopify webhook secret
162 pub const fn shopify_webhooks(secret_env: &'static str) -> SignatureConfig {
163 SignatureConfig {
164 algorithm: SignatureAlgorithm::HmacSha256Base64,
165 header_name: "x-shopify-hmac-sha256",
166 secret_env,
167 replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
168 }
169 }
170
171 /// Create an Ed25519 asymmetric signature config.
172 ///
173 /// The service signs the request body with an Ed25519 private key and publishes
174 /// the matching public key for you to verify with. The `public_key_env` variable
175 /// should hold the base64-encoded 32-byte Ed25519 public key.
176 ///
177 /// # Arguments
178 /// * `header` - The HTTP header containing the base64-encoded signature
179 /// * `public_key_env` - Environment variable containing the base64-encoded public key
180 pub const fn ed25519(header: &'static str, public_key_env: &'static str) -> SignatureConfig {
181 SignatureConfig {
182 algorithm: SignatureAlgorithm::Ed25519,
183 header_name: header,
184 secret_env: public_key_env,
185 replay_window_secs: DEFAULT_REPLAY_WINDOW_SECS,
186 }
187 }
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn test_signature_config_creation() {
197 let config = WebhookSignature::hmac_sha256("X-Hub-Signature-256", "GITHUB_SECRET");
198 assert_eq!(config.algorithm, SignatureAlgorithm::HmacSha256);
199 assert_eq!(config.header_name, "X-Hub-Signature-256");
200 assert_eq!(config.secret_env, "GITHUB_SECRET");
201 }
202
203 #[test]
204 fn test_algorithm_prefix() {
205 assert_eq!(SignatureAlgorithm::HmacSha256.prefix(), "sha256=");
206 assert_eq!(SignatureAlgorithm::StripeWebhooks.prefix(), "");
207 assert_eq!(SignatureAlgorithm::Ed25519.prefix(), "");
208 }
209
210 #[test]
211 fn test_idempotency_config_default_ttl() {
212 let config = IdempotencyConfig::new(IdempotencySource::Header("X-Id"));
213 assert_eq!(config.ttl, Duration::from_secs(24 * 60 * 60));
214 }
215}