Skip to main content

fraiseql_webhooks/signature/
github.rs

1//! GitHub webhook signature verification.
2//!
3//! Format: `sha256=<hex>`
4//! Algorithm: HMAC-SHA256
5
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8
9use crate::{
10    signature::{SignatureError, constant_time_eq},
11    traits::SignatureVerifier,
12};
13
14/// Verifies GitHub webhook signatures using HMAC-SHA256.
15///
16/// GitHub computes `HMAC-SHA256(secret, body)` and sends it as `sha256=<hex>`
17/// in the `X-Hub-Signature-256` header.
18pub struct GitHubVerifier;
19
20impl SignatureVerifier for GitHubVerifier {
21    fn name(&self) -> &'static str {
22        "github"
23    }
24
25    fn signature_header(&self) -> &'static str {
26        "X-Hub-Signature-256"
27    }
28
29    fn verify(
30        &self,
31        payload: &[u8],
32        signature: &str,
33        secret: &str,
34        _timestamp: Option<&str>,
35        _url: Option<&str>,
36    ) -> Result<bool, SignatureError> {
37        if secret.is_empty() {
38            return Err(SignatureError::Crypto(
39                "GitHub webhook secret must not be empty".to_string(),
40            ));
41        }
42        // GitHub format: sha256=<hex>
43        let sig_hex = signature.strip_prefix("sha256=").ok_or(SignatureError::InvalidFormat)?;
44
45        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
46            .map_err(|e| SignatureError::Crypto(e.to_string()))?;
47        mac.update(payload);
48
49        let expected = hex::encode(mac.finalize().into_bytes());
50
51        Ok(constant_time_eq(sig_hex.as_bytes(), expected.as_bytes()))
52    }
53}
54
55#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn generate_signature(payload: &[u8], secret: &str) -> String {
61        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
62        mac.update(payload);
63        format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
64    }
65
66    #[test]
67    fn test_valid_signature() {
68        let verifier = GitHubVerifier;
69        let payload = b"test payload";
70        let secret = "secret";
71        let signature = generate_signature(payload, secret);
72
73        assert!(verifier.verify(payload, &signature, secret, None, None).unwrap());
74    }
75
76    #[test]
77    fn test_invalid_signature() {
78        let verifier = GitHubVerifier;
79        let signature = "sha256=invalid";
80
81        assert!(!verifier.verify(b"test", signature, "secret", None, None).unwrap());
82    }
83
84    #[test]
85    fn test_missing_prefix() {
86        let verifier = GitHubVerifier;
87        let result = verifier.verify(b"test", "abc123", "secret", None, None);
88        assert!(matches!(result, Err(SignatureError::InvalidFormat)));
89    }
90}