Skip to main content

github_bot_sdk/webhook/
validation.rs

1//! Webhook signature validation implementation.
2//!
3//! Provides HMAC-SHA256 signature validation for GitHub webhooks using
4//! constant-time comparison to prevent timing attacks.
5
6use crate::auth::SecretProvider;
7use crate::error::ValidationError;
8use std::sync::Arc;
9
10/// Validates GitHub webhook signatures using HMAC-SHA256.
11///
12/// This validator ensures webhook payloads are authentic by verifying
13/// the `X-Hub-Signature-256` header against the payload using the
14/// webhook secret.
15///
16/// # Security
17///
18/// - Uses constant-time comparison to prevent timing attacks
19/// - Never logs secrets or signature values
20/// - Validates signature format before HMAC computation
21/// - Completes validation in under 100ms
22///
23/// # Examples
24///
25/// ```rust,no_run
26/// use github_bot_sdk::webhook::SignatureValidator;
27/// use github_bot_sdk::auth::SecretProvider;
28/// use std::sync::Arc;
29///
30/// # async fn example(secret_provider: Arc<dyn SecretProvider>) -> Result<(), Box<dyn std::error::Error>> {
31/// let validator = SignatureValidator::new(secret_provider);
32///
33/// let payload = b"{\"action\":\"opened\",\"number\":1}";
34/// let signature = "sha256=a1b2c3d4...";  // From X-Hub-Signature-256 header
35///
36/// if validator.validate(payload, signature).await? {
37///     println!("Valid webhook");
38/// } else {
39///     println!("Invalid signature - rejecting webhook");
40/// }
41/// # Ok(())
42/// # }
43/// ```
44#[derive(Clone)]
45pub struct SignatureValidator {
46    secrets: Arc<dyn SecretProvider>,
47}
48
49impl SignatureValidator {
50    /// Create a new signature validator.
51    ///
52    /// # Arguments
53    ///
54    /// * `secrets` - Provider for retrieving webhook secrets
55    ///
56    /// # Examples
57    ///
58    /// ```rust,no_run
59    /// # use github_bot_sdk::webhook::SignatureValidator;
60    /// # use github_bot_sdk::auth::SecretProvider;
61    /// # use std::sync::Arc;
62    /// # fn example(secret_provider: Arc<dyn SecretProvider>) {
63    /// let validator = SignatureValidator::new(secret_provider);
64    /// # }
65    /// ```
66    pub fn new(secrets: Arc<dyn SecretProvider>) -> Self {
67        Self { secrets }
68    }
69
70    /// Validate a webhook signature.
71    ///
72    /// Verifies that the signature matches the HMAC-SHA256 of the payload
73    /// using the webhook secret. Uses constant-time comparison to prevent
74    /// timing attacks.
75    ///
76    /// # Arguments
77    ///
78    /// * `payload` - The raw webhook payload bytes
79    /// * `signature` - The signature from X-Hub-Signature-256 header (format: "sha256=\<hex\>")
80    ///
81    /// # Returns
82    ///
83    /// * `Ok(true)` - Signature is valid
84    /// * `Ok(false)` - Signature is invalid (tampered payload or wrong secret)
85    /// * `Err` - Validation error (malformed signature, secret retrieval failure)
86    ///
87    /// # Examples
88    ///
89    /// ```rust,no_run
90    /// # use github_bot_sdk::webhook::SignatureValidator;
91    /// # use github_bot_sdk::auth::SecretProvider;
92    /// # use std::sync::Arc;
93    /// # async fn example(validator: SignatureValidator) -> Result<(), Box<dyn std::error::Error>> {
94    /// let payload = b"{\"action\":\"opened\"}";
95    /// let signature = "sha256=5c4a...";
96    ///
97    /// match validator.validate(payload, signature).await {
98    ///     Ok(true) => println!("Valid webhook"),
99    ///     Ok(false) => println!("Invalid signature"),
100    ///     Err(e) => println!("Validation error: {}", e),
101    /// }
102    /// # Ok(())
103    /// # }
104    /// ```
105    pub async fn validate(&self, payload: &[u8], signature: &str) -> Result<bool, ValidationError> {
106        // Parse the signature header
107        let signature_bytes = self.parse_signature(signature)?;
108
109        // Get webhook secret from provider
110        let secret = self.secrets.get_webhook_secret().await.map_err(|e| {
111            ValidationError::InvalidSignatureFormat {
112                message: format!("Failed to retrieve webhook secret: {}", e),
113            }
114        })?;
115
116        // Compute expected HMAC
117        let expected_hmac = self.compute_hmac(payload, &secret)?;
118
119        // Constant-time comparison
120        let is_valid = self.constant_time_compare(&signature_bytes, &expected_hmac);
121
122        Ok(is_valid)
123    }
124
125    /// Parse GitHub signature format.
126    ///
127    /// Extracts hex-encoded signature bytes from GitHub's "sha256=<hex>" format.
128    ///
129    /// # Arguments
130    ///
131    /// * `signature` - The signature header value
132    ///
133    /// # Returns
134    ///
135    /// The decoded signature bytes
136    ///
137    /// # Errors
138    ///
139    /// Returns `ValidationError::InvalidSignatureFormat` if:
140    /// - Signature doesn't start with "sha256="
141    /// - Hex encoding is invalid
142    fn parse_signature(&self, signature: &str) -> Result<Vec<u8>, ValidationError> {
143        // Check for "sha256=" prefix
144        const PREFIX: &str = "sha256=";
145        if !signature.starts_with(PREFIX) {
146            return Err(ValidationError::InvalidSignatureFormat {
147                message: format!(
148                    "Signature must start with '{}', got: '{}'",
149                    PREFIX,
150                    signature.chars().take(10).collect::<String>()
151                ),
152            });
153        }
154
155        // Extract hex portion
156        let hex_signature = &signature[PREFIX.len()..];
157
158        // Decode hex to bytes
159        hex::decode(hex_signature).map_err(|e| ValidationError::InvalidSignatureFormat {
160            message: format!("Invalid hex encoding in signature: {}", e),
161        })
162    }
163
164    /// Compute HMAC-SHA256 signature.
165    ///
166    /// Generates the expected HMAC-SHA256 signature for the payload
167    /// using the webhook secret.
168    ///
169    /// # Arguments
170    ///
171    /// * `payload` - The webhook payload bytes
172    /// * `secret` - The webhook secret
173    ///
174    /// # Returns
175    ///
176    /// The computed HMAC signature bytes
177    fn compute_hmac(&self, payload: &[u8], secret: &str) -> Result<Vec<u8>, ValidationError> {
178        use hmac::{Hmac, Mac};
179        use sha2::Sha256;
180
181        type HmacSha256 = Hmac<Sha256>;
182
183        // Create HMAC instance with secret
184        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
185            ValidationError::HmacError {
186                message: format!("Failed to create HMAC instance: {}", e),
187            }
188        })?;
189
190        // Update with payload
191        mac.update(payload);
192
193        // Finalize and return bytes
194        Ok(mac.finalize().into_bytes().to_vec())
195    }
196
197    /// Constant-time comparison of signatures.
198    ///
199    /// Compares two byte slices in constant time to prevent timing attacks.
200    /// Uses the `subtle` crate for cryptographically secure comparison.
201    ///
202    /// # Arguments
203    ///
204    /// * `a` - First signature
205    /// * `b` - Second signature
206    ///
207    /// # Returns
208    ///
209    /// `true` if signatures match, `false` otherwise
210    fn constant_time_compare(&self, a: &[u8], b: &[u8]) -> bool {
211        use subtle::ConstantTimeEq;
212
213        // Check length first (this is safe to do in non-constant time)
214        if a.len() != b.len() {
215            return false;
216        }
217
218        // Perform constant-time comparison
219        a.ct_eq(b).into()
220    }
221}
222
223// Security: Don't expose secrets in debug output
224impl std::fmt::Debug for SignatureValidator {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("SignatureValidator")
227            .field("secrets", &"<REDACTED>")
228            .finish()
229    }
230}
231
232#[cfg(test)]
233#[path = "validation_tests.rs"]
234mod tests;