use base64::Engine as _;
use hmac::{KeyInit, Mac, SimpleHmac};
use sha2::Sha256;
use crate::core::Secret;
pub const HEADER_ID: &str = "webhook-id";
pub const HEADER_TIMESTAMP: &str = "webhook-timestamp";
pub const HEADER_SIGNATURE: &str = "webhook-signature";
const SYMMETRIC_KEY_PREFIX: &str = "whsec_";
const MIN_KEY_BYTES: usize = 24;
fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
let mut mac = <SimpleHmac<Sha256> as KeyInit>::new_from_slice(key)
.expect("HMAC accepts a key of any length");
mac.update(message);
mac.finalize().into_bytes().into()
}
#[derive(Clone)]
pub struct BodySigning {
key: Vec<u8>,
}
impl std::fmt::Debug for BodySigning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BodySigning")
.field("key", &"<redacted>")
.finish()
}
}
impl Drop for BodySigning {
fn drop(&mut self) {
self.key.iter_mut().for_each(|byte| *byte = 0);
}
}
impl BodySigning {
#[must_use]
pub fn new(secret: &Secret) -> Self {
let raw = secret.expose();
let key = raw.strip_prefix(SYMMETRIC_KEY_PREFIX).map_or_else(
|| raw.as_bytes().to_vec(),
|encoded| {
base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap_or_else(|_| {
panic!(
"a push signing secret beginning '{SYMMETRIC_KEY_PREFIX}' names \
base64 of the key, and this one does not decode — every \
delivery would carry a MAC the receiver's library rejects"
)
})
},
);
assert!(
key.len() >= MIN_KEY_BYTES,
"a push signing key of {} bytes is shorter than the {MIN_KEY_BYTES} \
Standard Webhooks requires: a MAC key an attacker can search is a \
check that reads exactly like one that means something",
key.len()
);
Self { key }
}
pub(super) fn value_for(&self, id: &str, at: u64, body: &[u8]) -> String {
let mut content = Vec::with_capacity(id.len() + 24 + body.len());
content.extend_from_slice(id.as_bytes());
content.push(b'.');
content.extend_from_slice(at.to_string().as_bytes());
content.push(b'.');
content.extend_from_slice(body);
let mac = hmac_sha256(&self.key, &content);
format!(
"v1,{}",
base64::engine::general_purpose::STANDARD.encode(mac)
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn signing(secret: &str) -> BodySigning {
BodySigning::new(&Secret::new(secret))
}
#[test]
fn the_construction_matches_rfc_4231() {
assert_eq!(
hex::encode(hmac_sha256(&[0x0b; 20], b"Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7",
"RFC 4231 test case 1"
);
assert_eq!(
hex::encode(hmac_sha256(b"Jefe", b"what do ya want for nothing?")),
"5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843",
"RFC 4231 test case 2"
);
assert_eq!(
hex::encode(hmac_sha256(
&[0xaa; 131],
b"Test Using Larger Than Block-Size Key - Hash Key First"
)),
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54",
"RFC 4231 test case 6: a key longer than the block must be hashed first"
);
}
#[test]
fn the_signature_matches_the_standard_webhooks_example() {
let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
assert_eq!(
signing.value_for(
"msg_p5jXN8AQM9LWM0D4loKWxJek",
1_614_265_330,
b"{\"test\": 2432232314}"
),
"v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
"the spec's example verifies with every Standard Webhooks library, and \
a value of our own verifies with none of them"
);
}
#[test]
fn the_signature_follows_the_body_the_id_and_the_instant() {
let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
let value = signing.value_for("msg-1", 1_700_000_000, br#"{"a":1}"#);
assert!(
value.starts_with("v1,"),
"the version label is how a receiver dispatches: {value}"
);
assert_ne!(
value,
signing.value_for("msg-1", 1_700_000_000, br#"{"a":2}"#),
"one byte of the body changed and the signature did not"
);
assert_ne!(
value,
signing.value_for("msg-2", 1_700_000_000, br#"{"a":1}"#),
"the id is not covered, so a replay under another id verifies"
);
assert_ne!(
value,
signing.value_for("msg-1", 1_700_000_001, br#"{"a":1}"#),
"the instant is not covered, so a captured delivery never expires"
);
assert_ne!(
value,
BodySigning::new(&Secret::new(
"whsec_bm90LXRoZS1zYW1lLWtleS1hdC1hbGwtaGVyZQ=="
))
.value_for("msg-1", 1_700_000_000, br#"{"a":1}"#),
"a different key produced the same signature"
);
}
#[test]
fn a_signing_key_is_redacted() {
let signing = signing("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw");
let shown = format!("{signing:#?}");
assert!(!shown.contains("MfKQ"), "{shown}");
}
#[test]
#[should_panic(expected = "shorter than the 24")]
fn a_short_signing_key_is_refused_at_configuration() {
let _ = signing("too-short");
}
#[test]
#[should_panic(expected = "does not decode")]
fn a_whsec_secret_that_is_not_base64_is_refused_at_configuration() {
let _ = signing("whsec_not base64 at all !!!");
}
}