Skip to main content

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}