discourse_webhooks/
signature.rs

1use hex;
2use hmac::{Hmac, Mac};
3use sha2::Sha256;
4use thiserror::Error;
5
6#[derive(Error, Debug)]
7pub enum SignatureVerificationError {
8    #[error("Invalid hex encoding in signature: {0}")]
9    InvalidHex(#[from] hex::FromHexError),
10
11    #[error("Invalid HMAC key")]
12    InvalidKey,
13
14    #[error("Invalid signature format: {0}")]
15    InvalidFormat(String),
16
17    #[error("Signature verification failed")]
18    VerificationFailed,
19}
20
21/// Verify HMAC-SHA256 signature for webhook payload
22///
23/// # Arguments
24/// * `secret` - The shared secret key
25/// * `payload` - The JSON payload as a string
26/// * `signature` - The signature header from Discourse (format: "sha256=...")
27///
28/// # Returns
29/// * `Ok(())` if signature is valid
30/// * `Err(SignatureVerificationError)` if verification fails
31pub fn verify_signature(
32    secret: &str,
33    payload: &str,
34    signature: &str,
35) -> Result<(), SignatureVerificationError> {
36    let signature = signature.strip_prefix("sha256=").ok_or_else(|| {
37        SignatureVerificationError::InvalidFormat("Signature must start with 'sha256='".to_string())
38    })?;
39
40    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
41        .map_err(|_| SignatureVerificationError::InvalidKey)?;
42
43    mac.update(payload.as_bytes());
44
45    let expected = mac.finalize().into_bytes();
46    let expected_hex = hex::encode(expected);
47
48    if signature.eq_ignore_ascii_case(&expected_hex) {
49        Ok(())
50    } else {
51        Err(SignatureVerificationError::VerificationFailed)
52    }
53}
54
55/// Verify HMAC-SHA256 signature for JSON payload
56///
57/// Convenience function that serializes the JSON value to string first
58pub fn verify_json_signature(
59    secret: &str,
60    payload: &serde_json::Value,
61    signature: &str,
62) -> Result<(), SignatureVerificationError> {
63    let payload_str = serde_json::to_string(payload).map_err(|_| {
64        SignatureVerificationError::InvalidFormat("Failed to serialize JSON payload".to_string())
65    })?;
66
67    verify_signature(secret, &payload_str, signature)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use serde_json::json;
74
75    #[test]
76    fn test_signature_verification() {
77        let secret = "test_secret";
78        let payload = r#"{"test":"data"}"#;
79
80        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
81        mac.update(payload.as_bytes());
82        let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
83
84        assert!(verify_signature(secret, payload, &signature).is_ok());
85
86        assert!(verify_signature("wrong_secret", payload, &signature).is_err());
87
88        assert!(verify_signature(secret, payload, "invalid_format").is_err());
89    }
90
91    #[test]
92    fn test_json_signature_verification() {
93        let secret = "test_secret";
94        let payload = json!({"test": "data"});
95        let payload_str = serde_json::to_string(&payload).unwrap();
96
97        let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
98        mac.update(payload_str.as_bytes());
99        let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
100
101        assert!(verify_json_signature(secret, &payload, &signature).is_ok());
102    }
103}