Skip to main content

meritocrab_api/
extractors.rs

1use crate::{error::ApiError, state::AppState};
2use axum::{
3    extract::{FromRequest, Request},
4    http::header::HeaderMap,
5};
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8use subtle::ConstantTimeEq;
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Verified webhook payload extractor that works with AppState
13///
14/// This extractor validates the HMAC-SHA256 signature from GitHub webhooks.
15/// It extracts the `X-Hub-Signature-256` header and validates it against the request body.
16#[derive(Debug)]
17pub struct VerifiedWebhookPayload(pub Vec<u8>);
18
19impl FromRequest<AppState> for VerifiedWebhookPayload {
20    type Rejection = ApiError;
21
22    async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
23        let (parts, body) = req.into_parts();
24
25        // Extract signature from header
26        let signature = extract_signature(&parts.headers)?;
27
28        // Read body bytes
29        let body_bytes = axum::body::to_bytes(body, usize::MAX)
30            .await
31            .map_err(|e| ApiError::Internal(format!("Failed to read request body: {}", e)))?
32            .to_vec();
33
34        // Verify HMAC using webhook secret from app state
35        verify_signature(&body_bytes, &signature, state.webhook_secret.expose())?;
36
37        Ok(VerifiedWebhookPayload(body_bytes))
38    }
39}
40
41/// Extract signature from X-Hub-Signature-256 header
42fn extract_signature(headers: &HeaderMap) -> Result<Vec<u8>, ApiError> {
43    let signature_header = headers
44        .get("X-Hub-Signature-256")
45        .ok_or_else(|| {
46            ApiError::InvalidSignature("X-Hub-Signature-256 header not found".to_string())
47        })?
48        .to_str()
49        .map_err(|e| {
50            ApiError::InvalidSignature(format!("Invalid header encoding: {}", e))
51        })?;
52
53    // GitHub sends signature as "sha256=<hex>"
54    let signature_hex = signature_header
55        .strip_prefix("sha256=")
56        .ok_or_else(|| {
57            ApiError::InvalidSignature("Signature must start with 'sha256='".to_string())
58        })?;
59
60    // Decode hex to bytes
61    hex::decode(signature_hex).map_err(|e| {
62        ApiError::InvalidSignature(format!("Invalid hex encoding: {}", e))
63    })
64}
65
66/// Verify HMAC-SHA256 signature using constant-time comparison
67fn verify_signature(body: &[u8], signature: &[u8], secret: &str) -> Result<(), ApiError> {
68    let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| {
69        ApiError::Internal(format!("HMAC initialization failed: {}", e))
70    })?;
71
72    mac.update(body);
73    let expected = mac.finalize().into_bytes();
74
75    // Constant-time comparison to prevent timing attacks
76    if expected.ct_eq(signature).into() {
77        Ok(())
78    } else {
79        Err(ApiError::InvalidSignature(
80            "Signature mismatch".to_string(),
81        ))
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    fn compute_signature(body: &[u8], secret: &str) -> String {
90        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
91        mac.update(body);
92        let result = mac.finalize();
93        format!("sha256={}", hex::encode(result.into_bytes()))
94    }
95
96    #[test]
97    fn test_extract_signature_valid() {
98        let mut headers = HeaderMap::new();
99        headers.insert(
100            "X-Hub-Signature-256",
101            "sha256=0123456789abcdef".parse().unwrap(),
102        );
103
104        let result = extract_signature(&headers);
105        assert!(result.is_ok());
106    }
107
108    #[test]
109    fn test_extract_signature_missing() {
110        let headers = HeaderMap::new();
111        let result = extract_signature(&headers);
112        assert!(result.is_err());
113    }
114
115    #[test]
116    fn test_extract_signature_invalid_format() {
117        let mut headers = HeaderMap::new();
118        headers.insert(
119            "X-Hub-Signature-256",
120            "invalid-format".parse().unwrap(),
121        );
122
123        let result = extract_signature(&headers);
124        assert!(result.is_err());
125    }
126
127    #[test]
128    fn test_verify_signature_valid() {
129        let body = b"test body";
130        let secret = "test-secret";
131        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
132        mac.update(body);
133        let signature = mac.finalize().into_bytes();
134
135        let result = verify_signature(body, &signature, secret);
136        assert!(result.is_ok());
137    }
138
139    #[test]
140    fn test_verify_signature_invalid() {
141        let body = b"test body";
142        let secret = "test-secret";
143        let wrong_signature = [0u8; 32];
144
145        let result = verify_signature(body, &wrong_signature, secret);
146        assert!(result.is_err());
147    }
148}