use hmac::{KeyInit, Mac, SimpleHmac};
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::core::Secret;
use crate::core::secret::constant_time_eq;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum SigningKeyError {
#[error(
"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"
)]
NotBase64,
#[error(
"a push signing key of {bytes} 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"
)]
TooShort { bytes: usize },
#[error(
"a rotation secret was configured before any primary — sign with the \
primary first; 'also' without a first key is a wiring mistake worth \
naming where it is written"
)]
NoPrimary,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WebhookRejected {
#[error("the delivery carried no '{0}' header, so there is nothing to verify")]
MissingHeader(&'static str),
#[error("'{HEADER_TIMESTAMP}' is not Unix seconds: {0}")]
MalformedTimestamp(String),
#[error(
"'{HEADER_TIMESTAMP}' is {skew_secs}s from now, outside the {tolerance_secs}s \
tolerance — a signature proves the bytes, and only the window bounds how \
long a captured POST stays useful"
)]
Stale { skew_secs: i64, tolerance_secs: u64 },
#[error(
"'{HEADER_SIGNATURE}' carried no '{SCHEME}' signature — this build verifies \
'{SCHEME}' (HMAC-SHA256) only, so a header with other labels means a sender \
was deployed ahead of its receivers"
)]
NoSupportedScheme,
#[error(
"no configured key verifies this delivery — the bytes, the id or the \
timestamp are not what was signed, or the writer did not hold the key"
)]
SignatureMismatch,
}
pub const HEADER_ID: &str = "webhook-id";
pub const HEADER_TIMESTAMP: &str = "webhook-timestamp";
pub const HEADER_SIGNATURE: &str = "webhook-signature";
pub const HEADER_A2A_TOKEN: &str = "x-a2a-notification-token";
const SYMMETRIC_KEY_PREFIX: &str = "whsec_";
pub const SCHEME: &str = "v1";
pub const DEFAULT_TOLERANCE: std::time::Duration = std::time::Duration::from_secs(300);
pub 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 {
keys: Vec<Zeroizing<Vec<u8>>>,
}
impl std::fmt::Debug for BodySigning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BodySigning")
.field("keys", &"<redacted>")
.finish()
}
}
impl BodySigning {
#[must_use]
pub fn new(secret: &Secret) -> Self {
Self::try_new(secret).unwrap_or_else(|e| panic!("{e}"))
}
pub fn try_new(secret: &Secret) -> Result<Self, SigningKeyError> {
Ok(Self {
keys: vec![Self::key_bytes(secret)?],
})
}
pub fn try_also_with(mut self, secret: &Secret) -> Result<Self, SigningKeyError> {
self.keys.push(Self::key_bytes(secret)?);
Ok(self)
}
#[must_use]
pub fn also_with(self, secret: &Secret) -> Self {
self.try_also_with(secret).unwrap_or_else(|e| panic!("{e}"))
}
fn key_bytes(secret: &Secret) -> Result<Zeroizing<Vec<u8>>, SigningKeyError> {
let raw = secret.expose();
let key = Zeroizing::new(match raw.strip_prefix(SYMMETRIC_KEY_PREFIX) {
Some(encoded) => crate::core::b64::decode(encoded).ok_or(SigningKeyError::NotBase64)?,
None => raw.as_bytes().to_vec(),
});
if key.len() < MIN_KEY_BYTES {
return Err(SigningKeyError::TooShort { bytes: key.len() });
}
Ok(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);
self.keys
.iter()
.map(|key| {
let mac = hmac_sha256(key, &content);
format!("v1,{}", crate::core::b64::encode(mac))
})
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedDelivery {
pub id: String,
pub timestamp: u64,
}
#[derive(Debug, Clone)]
pub struct WebhookVerifier {
keys: Vec<BodySigning>,
tolerance: std::time::Duration,
}
impl WebhookVerifier {
pub fn new(secret: &Secret) -> Result<Self, SigningKeyError> {
Ok(Self {
keys: vec![BodySigning::try_new(secret)?],
tolerance: DEFAULT_TOLERANCE,
})
}
pub fn also_accepting(mut self, secret: &Secret) -> Result<Self, SigningKeyError> {
self.keys.push(BodySigning::try_new(secret)?);
Ok(self)
}
#[must_use]
pub const fn within(mut self, tolerance: std::time::Duration) -> Self {
self.tolerance = tolerance;
self
}
pub fn verify<'a, I>(
&self,
headers: I,
body: &[u8],
now: crate::core::Timestamp,
) -> Result<VerifiedDelivery, WebhookRejected>
where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
let mut id = None;
let mut timestamp = None;
let mut signature = None;
for (name, value) in headers {
if name.eq_ignore_ascii_case(HEADER_ID) {
id = Some(value);
} else if name.eq_ignore_ascii_case(HEADER_TIMESTAMP) {
timestamp = Some(value);
} else if name.eq_ignore_ascii_case(HEADER_SIGNATURE) {
signature = Some(value);
}
}
self.verify_parts(
id.ok_or(WebhookRejected::MissingHeader(HEADER_ID))?,
timestamp.ok_or(WebhookRejected::MissingHeader(HEADER_TIMESTAMP))?,
signature.ok_or(WebhookRejected::MissingHeader(HEADER_SIGNATURE))?,
body,
now,
)
}
pub fn verify_parts(
&self,
id: &str,
timestamp: &str,
signature: &str,
body: &[u8],
now: crate::core::Timestamp,
) -> Result<VerifiedDelivery, WebhookRejected> {
let at: u64 = timestamp
.trim()
.parse()
.map_err(|_| WebhookRejected::MalformedTimestamp(timestamp.to_owned()))?;
let skew = now
.unix_timestamp()
.saturating_sub(i64::try_from(at).unwrap_or(i64::MAX));
let tolerance_secs = self.tolerance.as_secs();
if skew.unsigned_abs() > tolerance_secs {
return Err(WebhookRejected::Stale {
skew_secs: skew,
tolerance_secs,
});
}
let offered: Vec<&str> = signature
.split_whitespace()
.filter_map(|part| part.strip_prefix(SCHEME).and_then(|r| r.strip_prefix(',')))
.collect();
if offered.is_empty() {
return Err(WebhookRejected::NoSupportedScheme);
}
let mut verified = false;
for key in &self.keys {
let expected = key.value_for(id, at, body);
for candidate in &offered {
verified |= constant_time_eq(
expected.as_bytes(),
format!("{SCHEME},{candidate}").as_bytes(),
);
}
}
if !verified {
return Err(WebhookRejected::SignatureMismatch);
}
Ok(VerifiedDelivery {
id: id.to_owned(),
timestamp: at,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rotating_sender_signs_under_both_keys_in_one_header() {
let old = Secret::new("whsec_C2FVsBQIhrscChlQIMV+b5sSYspob7oD");
let new = Secret::new("this-is-a-brand-new-signing-secret");
let both = BodySigning::new(&old).also_with(&new);
let value = both.value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}");
let parts: Vec<&str> = value.split(' ').collect();
assert_eq!(parts.len(), 2, "one element per key: {value}");
assert_eq!(
parts[0],
BodySigning::new(&old).value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}")
);
assert_eq!(
parts[1],
BodySigning::new(&new).value_for("msg_p5jXN8AQM9LWM0D4loKWxJek", 1_614_265_330, b"{}")
);
for part in parts {
assert!(part.starts_with("v1,"), "spec spelling per element: {part}");
}
}
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 !!!");
}
#[test]
fn the_fallible_constructor_refuses_exactly_what_the_panicking_one_does() {
assert_eq!(
BodySigning::try_new(&Secret::new("too-short")).unwrap_err(),
SigningKeyError::TooShort { bytes: 9 }
);
assert_eq!(
BodySigning::try_new(&Secret::new("whsec_not base64 at all !!!")).unwrap_err(),
SigningKeyError::NotBase64
);
assert!(
BodySigning::try_new(&Secret::new("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")).is_ok(),
"the spec's own example key must be accepted"
);
}
const KEY: &str = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
const BODY: &[u8] = br#"{"claim":"C-1","amount":900}"#;
const AT: u64 = 1_700_000_000;
fn verifier() -> WebhookVerifier {
WebhookVerifier::new(&Secret::new(KEY)).expect("the spec's own key")
}
fn now(secs: i64) -> crate::core::Timestamp {
crate::core::Timestamp::from_unix_timestamp(secs).expect("representable")
}
fn headers(id: &str, at: u64, sig: &str) -> Vec<(String, String)> {
vec![
(HEADER_ID.to_owned(), id.to_owned()),
(HEADER_TIMESTAMP.to_owned(), at.to_string()),
(HEADER_SIGNATURE.to_owned(), sig.to_owned()),
]
}
fn verify(
v: &WebhookVerifier,
h: &[(String, String)],
body: &[u8],
at: i64,
) -> Result<VerifiedDelivery, WebhookRejected> {
v.verify(
h.iter().map(|(k, val)| (k.as_str(), val.as_str())),
body,
now(at),
)
}
#[test]
fn a_delivery_this_plane_signed_verifies_and_yields_its_dedup_key() {
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let ok = verify(
&verifier(),
&headers("msg-1", AT, &sig),
BODY,
AT.cast_signed(),
)
.expect("a delivery we just signed");
assert_eq!(
ok,
VerifiedDelivery {
id: "msg-1".to_owned(),
timestamp: AT
},
"the id must come back: it is the receiver's idempotency key, and handing it over is the point of returning a value at all"
);
}
#[test]
fn a_delivery_whose_body_id_or_instant_was_edited_is_refused() {
let v = verifier();
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let at = AT.cast_signed();
for (what, headers, body) in [
(
"the body",
headers("msg-1", AT, &sig),
br#"{"claim":"C-1","amount":9000}"#.as_slice(),
),
("the id", headers("msg-2", AT, &sig), BODY),
("the instant", headers("msg-1", AT + 1, &sig), BODY),
] {
assert_eq!(
verify(&v, &headers, body, at).unwrap_err(),
WebhookRejected::SignatureMismatch,
"{what} was edited in transit and the delivery still verified"
);
}
}
#[test]
fn a_captured_delivery_stops_verifying_once_it_is_stale() {
let v = verifier();
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let h = headers("msg-1", AT, &sig);
let at = AT.cast_signed();
assert!(
verify(&v, &h, BODY, at + 299).is_ok(),
"inside the window a genuine delivery must still be accepted"
);
assert!(
matches!(
verify(&v, &h, BODY, at + 301).unwrap_err(),
WebhookRejected::Stale { .. }
),
"a delivery older than the tolerance is a replay this receiver cannot tell from the original"
);
assert!(
matches!(
verify(&v, &h, BODY, at - 301).unwrap_err(),
WebhookRejected::Stale { .. }
),
"a delivery from the future is a clock nobody can reason about"
);
assert!(
verify(
&v.within(std::time::Duration::from_secs(3600)),
&h,
BODY,
at + 3000
)
.is_ok(),
"a receiver behind a slow queue must be able to widen the window deliberately rather than discover it at the far end of a backlog"
);
}
#[test]
fn a_timestamp_at_the_edge_of_its_type_is_stale() {
let v = verifier();
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let now = AT.cast_signed();
for hostile in [u64::MAX, i64::MAX.unsigned_abs(), 0] {
let h = headers("msg-1", hostile, &sig);
assert!(
matches!(
verify(&v, &h, BODY, now).unwrap_err(),
WebhookRejected::Stale { .. }
),
"a timestamp of {hostile} must be refused as stale"
);
}
}
#[test]
fn a_delivery_with_no_signature_is_refused_rather_than_waved_through() {
let v = verifier();
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let at = AT.cast_signed();
let full = headers("msg-1", AT, &sig);
for (missing, name) in [
(HEADER_SIGNATURE, HEADER_SIGNATURE),
(HEADER_ID, HEADER_ID),
(HEADER_TIMESTAMP, HEADER_TIMESTAMP),
] {
let without: Vec<_> = full.iter().filter(|(k, _)| k != missing).cloned().collect();
assert_eq!(
verify(&v, &without, BODY, at).unwrap_err(),
WebhookRejected::MissingHeader(name)
);
}
}
#[test]
fn header_names_are_matched_case_insensitively() {
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let shouting = vec![
("Webhook-Id".to_owned(), "msg-1".to_owned()),
("WEBHOOK-TIMESTAMP".to_owned(), AT.to_string()),
("Webhook-Signature".to_owned(), sig),
];
assert!(verify(&verifier(), &shouting, BODY, AT.cast_signed()).is_ok());
}
#[test]
fn a_key_rotation_verifies_from_both_directions() {
let old = "whsec_bm90LXRoZS1zYW1lLWtleS1hdC1hbGwtaGVyZQ==";
let v = verifier()
.also_accepting(&Secret::new(old))
.expect("a valid second key");
let at = AT.cast_signed();
let by_old = signing(old).value_for("msg-1", AT, BODY);
assert!(verify(&v, &headers("msg-1", AT, &by_old), BODY, at).is_ok());
let by_new = signing(KEY).value_for("msg-1", AT, BODY);
let only_new = verifier();
for pair in [format!("{by_old} {by_new}"), format!("{by_new} {by_old}")] {
assert!(
verify(&only_new, &headers("msg-1", AT, &pair), BODY, at).is_ok(),
"a receiver holding one key must find its signature anywhere in the offered list: {pair}"
);
}
}
#[test]
fn an_unknown_scheme_is_told_apart_from_a_bad_signature() {
let v = verifier();
assert_eq!(
verify(
&v,
&headers("msg-1", AT, "v1a,c29tZXRoaW5nCg=="),
BODY,
AT.cast_signed()
)
.unwrap_err(),
WebhookRejected::NoSupportedScheme
);
assert_eq!(
verify(
&v,
&headers("msg-1", AT, "not-a-scheme"),
BODY,
AT.cast_signed()
)
.unwrap_err(),
WebhookRejected::NoSupportedScheme
);
}
#[test]
fn a_malformed_timestamp_is_refused_by_name() {
let sig = signing(KEY).value_for("msg-1", AT, BODY);
let h = vec![
(HEADER_ID.to_owned(), "msg-1".to_owned()),
(HEADER_TIMESTAMP.to_owned(), "yesterday".to_owned()),
(HEADER_SIGNATURE.to_owned(), sig),
];
assert!(matches!(
verify(&verifier(), &h, BODY, AT.cast_signed()).unwrap_err(),
WebhookRejected::MalformedTimestamp(_)
));
}
#[test]
fn the_comparison_is_constant_time_in_shape() {
assert!(constant_time_eq(b"abcdef", b"abcdef"));
assert!(!constant_time_eq(b"abcdef", b"abcdeg"));
assert!(
!constant_time_eq(b"abcdef", b"abcde"),
"a length difference is not a match"
);
assert!(
!constant_time_eq(b"zbcdef", b"abcdef"),
"differing in the first byte is refused exactly as differing in the last"
);
}
}