Skip to main content

githubclaw/
signature.rs

1//! Webhook signature verification using HMAC SHA-256.
2//!
3//! Translates the Python `signature.py` module to Rust.
4//! Verifies GitHub webhook payloads against the `X-Hub-Signature-256` header.
5
6use hmac::{Hmac, Mac};
7use sha2::Sha256;
8
9type HmacSha256 = Hmac<Sha256>;
10
11/// Verify a GitHub webhook payload against the `X-Hub-Signature-256` header.
12///
13/// # Arguments
14///
15/// * `payload_body` - Raw bytes of the request body.
16/// * `signature_header` - Value of the `X-Hub-Signature-256` header (e.g. `"sha256=abc123..."`).
17/// * `secret` - The webhook secret shared with GitHub.
18///
19/// # Returns
20///
21/// `true` if the signature is valid, `false` otherwise.
22pub fn verify_webhook_signature(payload_body: &[u8], signature_header: &str, secret: &str) -> bool {
23    if signature_header.is_empty() {
24        return false;
25    }
26
27    let hex_signature = match signature_header.strip_prefix("sha256=") {
28        Some(hex) => hex,
29        None => return false,
30    };
31
32    let signature_bytes = match hex::decode(hex_signature) {
33        Ok(bytes) => bytes,
34        Err(_) => return false,
35    };
36
37    let mut mac =
38        HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
39    mac.update(payload_body);
40
41    // `verify_slice` performs constant-time comparison.
42    mac.verify_slice(&signature_bytes).is_ok()
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use hmac::Mac;
49
50    /// Helper: compute a valid sha256=<hex> signature for the given payload and secret.
51    fn compute_signature(payload: &[u8], secret: &str) -> String {
52        let mut mac =
53            HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC can take key of any size");
54        mac.update(payload);
55        let result = mac.finalize();
56        format!("sha256={}", hex::encode(result.into_bytes()))
57    }
58
59    #[test]
60    fn valid_signature_returns_true() {
61        let payload = b"hello world";
62        let secret = "my-secret";
63        let sig = compute_signature(payload, secret);
64        assert!(verify_webhook_signature(payload, &sig, secret));
65    }
66
67    #[test]
68    fn invalid_signature_returns_false() {
69        let payload = b"hello world";
70        let secret = "my-secret";
71        let bad_sig = "sha256=0000000000000000000000000000000000000000000000000000000000000000";
72        assert!(!verify_webhook_signature(payload, bad_sig, secret));
73    }
74
75    #[test]
76    fn empty_signature_header_returns_false() {
77        assert!(!verify_webhook_signature(b"payload", "", "secret"));
78    }
79
80    #[test]
81    fn missing_sha256_prefix_returns_false() {
82        let payload = b"hello world";
83        let secret = "my-secret";
84        let sig = compute_signature(payload, secret);
85        // Strip the "sha256=" prefix so only the hex remains.
86        let hex_only = sig.strip_prefix("sha256=").unwrap();
87        assert!(!verify_webhook_signature(payload, hex_only, secret));
88    }
89
90    #[test]
91    fn invalid_hex_in_signature_returns_false() {
92        assert!(!verify_webhook_signature(
93            b"payload",
94            "sha256=not-valid-hex!!!",
95            "secret"
96        ));
97    }
98
99    #[test]
100    fn different_payload_produces_different_signature() {
101        let secret = "shared-secret";
102        let sig_a = compute_signature(b"payload-a", secret);
103        let sig_b = compute_signature(b"payload-b", secret);
104        assert_ne!(sig_a, sig_b);
105        assert!(!verify_webhook_signature(b"payload-a", &sig_b, secret));
106    }
107
108    #[test]
109    fn different_secret_produces_different_signature() {
110        let payload = b"same payload";
111        let sig_a = compute_signature(payload, "secret-a");
112        let sig_b = compute_signature(payload, "secret-b");
113        assert_ne!(sig_a, sig_b);
114        assert!(!verify_webhook_signature(payload, &sig_a, "secret-b"));
115    }
116}