Skip to main content

fizzy_sdk/
webhooks.rs

1//! Verifying what Fizzy posts to a webhook. Every delivery carries an `X-Webhook-Signature`
2//! header: the hex HMAC-SHA256 of the raw body under the webhook's secret.
3//!
4//! ```
5//! use fizzy_sdk::webhooks::{SIGNATURE_HEADER, compute_signature, verify_signature};
6//!
7//! let secret = "whsec_test";
8//! let body = br#"{"event":"card.created"}"#;
9//! let signature = compute_signature(body, secret);
10//! assert!(verify_signature(body, &signature, secret));
11//! assert!(!verify_signature(b"tampered", &signature, secret));
12//! assert_eq!(SIGNATURE_HEADER, "X-Webhook-Signature");
13//! ```
14
15use sha2::{Digest, Sha256};
16
17/// The header a delivery's signature arrives in.
18pub const SIGNATURE_HEADER: &str = "X-Webhook-Signature";
19
20const BLOCK_SIZE: usize = 64;
21
22/// Whether `signature` is the HMAC-SHA256 of `payload` under `secret`, compared in constant
23/// time. An empty secret or signature never verifies.
24pub fn verify_signature(payload: &[u8], signature: &str, secret: &str) -> bool {
25    if secret.is_empty() || signature.is_empty() {
26        return false;
27    }
28    constant_time_eq(
29        compute_signature(payload, secret).as_bytes(),
30        signature.trim().as_bytes(),
31    )
32}
33
34/// The hex HMAC-SHA256 of `payload` under `secret`, as Fizzy signs a delivery.
35pub fn compute_signature(payload: &[u8], secret: &str) -> String {
36    hex(&hmac_sha256(secret.as_bytes(), payload))
37}
38
39/// HMAC as RFC 2104 defines it over SHA-256: a key longer than the block is hashed first,
40/// then padded; the inner hash goes over `ipad ‖ message`, the outer over
41/// `opad ‖ inner`.
42fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
43    let mut block = [0u8; BLOCK_SIZE];
44    if key.len() > BLOCK_SIZE {
45        block[..32].copy_from_slice(&Sha256::digest(key));
46    } else {
47        block[..key.len()].copy_from_slice(key);
48    }
49    let mut inner = Sha256::new();
50    inner.update(block.map(|byte| byte ^ 0x36));
51    inner.update(message);
52    let inner = inner.finalize();
53    let mut outer = Sha256::new();
54    outer.update(block.map(|byte| byte ^ 0x5c));
55    outer.update(inner);
56    outer.finalize().into()
57}
58
59/// Whether two byte strings are equal, taking the same time whatever the answer: the
60/// comparison walks every byte of both and folds the differences, so a mismatch in the
61/// first byte costs no less than one in the last. Lengths are folded in the same way.
62fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
63    let mut difference = a.len() ^ b.len();
64    for index in 0..a.len().max(b.len()) {
65        let left = a.get(index).copied().unwrap_or(0);
66        let right = b.get(index).copied().unwrap_or(0);
67        difference |= usize::from(left ^ right);
68    }
69    difference == 0
70}
71
72fn hex(bytes: &[u8]) -> String {
73    use std::fmt::Write;
74    bytes.iter().fold(String::new(), |mut out, byte| {
75        let _ = write!(out, "{byte:02x}");
76        out
77    })
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    /// RFC 4231 test case 2: the key "Jefe" over "what do ya want for nothing?".
85    #[test]
86    fn hmac_matches_the_rfc_4231_vector() {
87        assert_eq!(
88            compute_signature(b"what do ya want for nothing?", "Jefe"),
89            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
90        );
91    }
92
93    /// RFC 4231 test case 6: a key longer than the block size is hashed first.
94    #[test]
95    fn a_long_key_is_hashed_before_use() {
96        let key = "\u{aa}".repeat(131).into_bytes();
97        let key: Vec<u8> = key.iter().filter(|byte| **byte == 0xaa).copied().collect();
98        assert_eq!(key.len(), 131);
99        let message = b"Test Using Larger Than Block-Size Key - Hash Key First";
100        assert_eq!(
101            hex(&hmac_sha256(&key, message)),
102            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
103        );
104    }
105
106    #[test]
107    fn verification_needs_a_secret_and_a_signature_and_the_same_bytes() {
108        let signature = compute_signature(b"payload", "secret");
109        assert!(verify_signature(b"payload", &signature, "secret"));
110        assert!(verify_signature(
111            b"payload",
112            &format!(" {signature}\n"),
113            "secret"
114        ));
115        assert!(!verify_signature(b"payload", &signature, "other"));
116        assert!(!verify_signature(b"other", &signature, "secret"));
117        assert!(!verify_signature(b"payload", "", "secret"));
118        assert!(!verify_signature(b"payload", &signature, ""));
119        assert!(!verify_signature(b"payload", &signature[..10], "secret"));
120    }
121
122    #[test]
123    fn constant_time_comparison_is_exact() {
124        assert!(constant_time_eq(b"abc", b"abc"));
125        assert!(!constant_time_eq(b"abc", b"abd"));
126        assert!(!constant_time_eq(b"abc", b"ab"));
127        assert!(!constant_time_eq(b"", b"a"));
128        assert!(constant_time_eq(b"", b""));
129    }
130}