Skip to main content

jerrycan_auth/
webhook.rs

1//! Webhook signature verification primitives: HMAC over the EXACT request
2//! bytes (pair with the `RawBody` extractor — re-serialized JSON never
3//! verifies). Comparisons are constant-time via `hmac`'s `verify_slice`.
4
5use base64::Engine;
6use hmac::{Hmac, Mac};
7use sha1::Sha1;
8use sha2::Sha256;
9
10/// Hex HMAC-SHA256 of `message` — the scheme Stripe (`v1=`), GitHub
11/// (`sha256=`), and most modern providers use.
12pub fn sign_sha256_hex(secret: &[u8], message: &[u8]) -> String {
13    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("hmac accepts any key length");
14    mac.update(message);
15    hex_encode(&mac.finalize().into_bytes())
16}
17
18/// Verifies a hex HMAC-SHA256 signature in constant time. Case-insensitive
19/// hex; malformed hex simply fails verification.
20pub fn verify_sha256_hex(secret: &[u8], message: &[u8], signature_hex: &str) -> bool {
21    let Some(signature) = hex_decode(signature_hex) else {
22        return false;
23    };
24    let mut mac = Hmac::<Sha256>::new_from_slice(secret).expect("hmac accepts any key length");
25    mac.update(message);
26    mac.verify_slice(&signature).is_ok()
27}
28
29/// Base64 HMAC-SHA1 of `message` — Twilio's `X-Twilio-Signature` scheme
30/// (the URL with sorted POST params appended). SHA-1 is what Twilio specifies;
31/// HMAC-SHA1 remains secure as a MAC even though SHA-1 is broken for collision
32/// resistance.
33pub fn sign_sha1_base64(secret: &[u8], message: &[u8]) -> String {
34    let mut mac = Hmac::<Sha1>::new_from_slice(secret).expect("hmac accepts any key length");
35    mac.update(message);
36    base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
37}
38
39/// Verifies a base64 HMAC-SHA1 signature in constant time. Malformed base64
40/// simply fails verification.
41pub fn verify_sha1_base64(secret: &[u8], message: &[u8], signature_b64: &str) -> bool {
42    let Ok(signature) = base64::engine::general_purpose::STANDARD.decode(signature_b64) else {
43        return false;
44    };
45    let mut mac = Hmac::<Sha1>::new_from_slice(secret).expect("hmac accepts any key length");
46    mac.update(message);
47    mac.verify_slice(&signature).is_ok()
48}
49
50/// Lowercase-hex encode raw MAC bytes. Shared with `api_key` (key-hash column).
51pub(crate) fn hex_encode(bytes: &[u8]) -> String {
52    let mut out = String::with_capacity(bytes.len() * 2);
53    for b in bytes {
54        out.push(char::from_digit((b >> 4) as u32, 16).expect("nibble < 16"));
55        out.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble < 16"));
56    }
57    out
58}
59
60/// Decode a hex string (upper- or lowercase). Returns `None` for odd length or
61/// any non-hex character — never panics, so untrusted signatures are safe.
62fn hex_decode(s: &str) -> Option<Vec<u8>> {
63    if !s.len().is_multiple_of(2) {
64        return None;
65    }
66    let bytes = s.as_bytes();
67    let mut out = Vec::with_capacity(s.len() / 2);
68    for pair in bytes.chunks_exact(2) {
69        let hi = (pair[0] as char).to_digit(16)?;
70        let lo = (pair[1] as char).to_digit(16)?;
71        out.push((hi * 16 + lo) as u8);
72    }
73    Some(out)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn sha256_hex_roundtrip_and_tamper_detection() {
82        let secret = b"whsec_test";
83        let message = b"1717000000.{\"id\":\"evt_1\"}";
84        let signature = sign_sha256_hex(secret, message);
85        assert_eq!(signature.len(), 64);
86        assert!(signature.chars().all(|c| c.is_ascii_hexdigit()));
87        assert!(verify_sha256_hex(secret, message, &signature));
88        assert!(!verify_sha256_hex(secret, b"tampered", &signature));
89        assert!(!verify_sha256_hex(b"wrong-secret", message, &signature));
90        // Uppercase hex also accepted (providers vary).
91        assert!(verify_sha256_hex(
92            secret,
93            message,
94            &signature.to_uppercase()
95        ));
96        // Garbage that is not hex never verifies (and never panics).
97        assert!(!verify_sha256_hex(secret, message, "zz!"));
98        assert!(!verify_sha256_hex(secret, message, ""));
99    }
100
101    #[test]
102    fn sha1_base64_roundtrip_and_tamper_detection() {
103        let secret = b"twilio_auth_token";
104        let message = b"https://example.test/hookBody=value";
105        let signature = sign_sha1_base64(secret, message);
106        assert!(verify_sha1_base64(secret, message, &signature));
107        assert!(!verify_sha1_base64(secret, b"tampered", &signature));
108        assert!(!verify_sha1_base64(secret, message, "not base64!!"));
109    }
110
111    /// Known-answer test: RFC 2202 test case 2 for HMAC-SHA1 ("what do ya want
112    /// for nothing?" with key "Jefe") and RFC 4231 test case 2 for HMAC-SHA256 —
113    /// proves we compute the real algorithms, not just roundtrip-consistent ones.
114    #[test]
115    fn known_answer_vectors() {
116        assert_eq!(
117            sign_sha256_hex(b"Jefe", b"what do ya want for nothing?"),
118            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
119        );
120        use base64::Engine as _;
121        let mac = sign_sha1_base64(b"Jefe", b"what do ya want for nothing?");
122        let raw = base64::engine::general_purpose::STANDARD
123            .decode(&mac)
124            .unwrap();
125        assert_eq!(
126            raw.iter().map(|b| format!("{b:02x}")).collect::<String>(),
127            "effcdf6ae5eb2fa2d27416d5f184df9c259a7c79"
128        );
129    }
130}