Skip to main content

tatara_github_watcher/
verify.rs

1//! HMAC-SHA256 verification of GitHub's `X-Hub-Signature-256` header.
2
3use hmac::{Hmac, Mac};
4use sha2::Sha256;
5use subtle::ConstantTimeEq;
6
7type HmacSha256 = Hmac<Sha256>;
8
9/// Reasons a signature can fail.
10#[derive(Debug, thiserror::Error, PartialEq, Eq)]
11pub enum VerifyError {
12    /// Header missing or empty.
13    #[error("missing signature header")]
14    MissingHeader,
15    /// Header doesn't start with `sha256=`.
16    #[error("malformed signature header (expected sha256= prefix)")]
17    BadPrefix,
18    /// Header isn't valid hex.
19    #[error("signature is not valid hex")]
20    BadHex,
21    /// HMAC didn't match.
22    #[error("signature mismatch")]
23    Mismatch,
24}
25
26/// Verify the `X-Hub-Signature-256` against the secret + body.
27///
28/// `header_value` is the raw header value (e.g.,
29/// `"sha256=abc123..."`). `body` is the raw HTTP body bytes.
30/// `secret` is the shared webhook secret (operator-supplied).
31///
32/// Constant-time comparison. Pure.
33pub fn verify_signature(header_value: &str, body: &[u8], secret: &[u8]) -> Result<(), VerifyError> {
34    if header_value.is_empty() {
35        return Err(VerifyError::MissingHeader);
36    }
37    let hex_part = header_value
38        .strip_prefix("sha256=")
39        .ok_or(VerifyError::BadPrefix)?;
40    let supplied = hex::decode(hex_part).map_err(|_| VerifyError::BadHex)?;
41
42    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
43    mac.update(body);
44    let computed = mac.finalize().into_bytes();
45
46    if computed.as_slice().ct_eq(supplied.as_slice()).into() {
47        Ok(())
48    } else {
49        Err(VerifyError::Mismatch)
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    fn make_signature(body: &[u8], secret: &[u8]) -> String {
58        let mut mac = HmacSha256::new_from_slice(secret).unwrap();
59        mac.update(body);
60        let bytes = mac.finalize().into_bytes();
61        format!("sha256={}", hex::encode(bytes))
62    }
63
64    #[test]
65    fn round_trip_matches() {
66        let body = b"hello-world";
67        let secret = b"super-secret";
68        let sig = make_signature(body, secret);
69        assert!(verify_signature(&sig, body, secret).is_ok());
70    }
71
72    #[test]
73    fn empty_header_is_missing() {
74        assert_eq!(
75            verify_signature("", b"any", b"any"),
76            Err(VerifyError::MissingHeader)
77        );
78    }
79
80    #[test]
81    fn wrong_prefix_rejected() {
82        assert_eq!(
83            verify_signature("md5=abc", b"any", b"any"),
84            Err(VerifyError::BadPrefix)
85        );
86    }
87
88    #[test]
89    fn non_hex_rejected() {
90        assert_eq!(
91            verify_signature("sha256=not-hex!!", b"any", b"any"),
92            Err(VerifyError::BadHex)
93        );
94    }
95
96    #[test]
97    fn tampered_body_mismatches() {
98        let body = b"original";
99        let secret = b"k";
100        let sig = make_signature(body, secret);
101        assert_eq!(
102            verify_signature(&sig, b"tampered", secret),
103            Err(VerifyError::Mismatch)
104        );
105    }
106
107    #[test]
108    fn different_secret_mismatches() {
109        let body = b"x";
110        let sig = make_signature(body, b"secret-a");
111        assert_eq!(
112            verify_signature(&sig, body, b"secret-b"),
113            Err(VerifyError::Mismatch)
114        );
115    }
116
117    #[test]
118    fn empty_secret_still_works_for_known_pair() {
119        // Edge case: HMAC accepts any key length including empty.
120        let body = b"x";
121        let sig = make_signature(body, b"");
122        assert!(verify_signature(&sig, body, b"").is_ok());
123    }
124}