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;