use hmac::{Hmac, Mac};
use sha2::Sha256;
use snafu::Snafu;
use super::WebhookEvent;
const SHA256_PREFIX: &str = "sha256=";
const SHA1_PREFIX: &str = "sha1=";
const SHA256_DIGEST_LEN: usize = 32;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Snafu, Clone, PartialEq)]
#[snafu(visibility(pub))]
pub enum WebhookVerificationError {
#[snafu(display("Missing or invalid signature prefix: expected 'sha256='"))]
MissingPrefix,
#[snafu(display(
"SHA-1 signatures ('sha1=') are deprecated by GitHub; please use 'X-Hub-Signature-256' with HMAC-SHA256"
))]
DeprecatedSha1,
#[snafu(display("Invalid signature hex encoding: {source}"))]
InvalidHexEncoding { source: hex::FromHexError },
#[snafu(display("Invalid signature length: expected {expected} bytes, got {actual}"))]
InvalidLength { expected: usize, actual: usize },
#[snafu(display("HMAC signature mismatch: payload does not match the provided signature"))]
Mismatch,
#[snafu(display("Invalid secret key: {message}"))]
InvalidKey { message: String },
}
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum WebhookEventError {
#[snafu(display("Webhook signature verification failed: {source}"))]
Verification { source: WebhookVerificationError },
#[snafu(display("Webhook JSON deserialization failed: {source}"))]
Json { source: serde_json::Error },
}
impl From<WebhookVerificationError> for WebhookEventError {
fn from(err: WebhookVerificationError) -> Self {
Self::Verification { source: err }
}
}
impl From<serde_json::Error> for WebhookEventError {
fn from(err: serde_json::Error) -> Self {
Self::Json { source: err }
}
}
pub fn verify_signature(
signature_header: &str,
secret: impl AsRef<[u8]>,
body: impl AsRef<[u8]>,
) -> Result<(), WebhookVerificationError> {
let trimmed = signature_header.trim();
if trimmed.starts_with(SHA1_PREFIX) {
return Err(WebhookVerificationError::DeprecatedSha1);
}
let hex_signature = trimmed
.strip_prefix(SHA256_PREFIX)
.ok_or(WebhookVerificationError::MissingPrefix)?;
let signature_bytes = hex::decode(hex_signature)
.map_err(|source| WebhookVerificationError::InvalidHexEncoding { source })?;
if signature_bytes.len() != SHA256_DIGEST_LEN {
return Err(WebhookVerificationError::InvalidLength {
expected: SHA256_DIGEST_LEN,
actual: signature_bytes.len(),
});
}
let mut mac = HmacSha256::new_from_slice(secret.as_ref()).map_err(|e| {
WebhookVerificationError::InvalidKey {
message: e.to_string(),
}
})?;
mac.update(body.as_ref());
mac.verify_slice(&signature_bytes)
.map_err(|_| WebhookVerificationError::Mismatch)?;
Ok(())
}
#[derive(Clone, Debug)]
pub struct WebhookVerifier {
secret: Vec<u8>,
}
impl WebhookVerifier {
pub fn new(secret: impl AsRef<[u8]>) -> Self {
Self {
secret: secret.as_ref().to_vec(),
}
}
pub fn verify(
&self,
signature_header: &str,
body: impl AsRef<[u8]>,
) -> Result<(), WebhookVerificationError> {
verify_signature(signature_header, &self.secret, body)
}
pub fn verify_and_parse<B>(
&self,
event_header: &str,
signature_header: &str,
body: &B,
) -> Result<WebhookEvent, WebhookEventError>
where
B: AsRef<[u8]> + ?Sized,
{
self.verify(signature_header, body.as_ref())?;
WebhookEvent::try_from_header_and_body(event_header, body).map_err(WebhookEventError::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"It's a Secret to Everybody";
const PAYLOAD: &[u8] = b"Hello, World!";
const VALID_SIG: &str =
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
#[test]
fn verify_valid_signature() {
assert!(verify_signature(VALID_SIG, SECRET, PAYLOAD).is_ok());
}
#[test]
fn verify_valid_signature_with_whitespace() {
let sig = format!(" {}\n", VALID_SIG);
assert!(verify_signature(&sig, SECRET, PAYLOAD).is_ok());
}
#[test]
fn verify_valid_signature_with_verifier_struct() {
let verifier = WebhookVerifier::new(SECRET);
assert!(verifier.verify(VALID_SIG, PAYLOAD).is_ok());
}
#[test]
fn verify_fails_on_tampered_payload() {
let tampered = b"Hello, World?";
assert_eq!(
verify_signature(VALID_SIG, SECRET, tampered),
Err(WebhookVerificationError::Mismatch)
);
}
#[test]
fn verify_fails_on_wrong_secret() {
assert_eq!(
verify_signature(VALID_SIG, b"wrong_secret", PAYLOAD),
Err(WebhookVerificationError::Mismatch)
);
}
#[test]
fn verify_fails_on_missing_prefix() {
let raw_hex = &VALID_SIG[7..];
assert_eq!(
verify_signature(raw_hex, SECRET, PAYLOAD),
Err(WebhookVerificationError::MissingPrefix)
);
}
#[test]
fn verify_fails_on_deprecated_sha1() {
let sha1_sig = "sha1=2a6f81a7cf73cfb841e25eecb23e75e927595c55";
assert_eq!(
verify_signature(sha1_sig, SECRET, PAYLOAD),
Err(WebhookVerificationError::DeprecatedSha1)
);
}
#[test]
fn verify_fails_on_invalid_hex() {
let invalid_hex =
"sha256=zz4a4e5e228634e32654b443e32f65f13361b1834399b727e36bbd0f5add4b712";
assert!(matches!(
verify_signature(invalid_hex, SECRET, PAYLOAD),
Err(WebhookVerificationError::InvalidHexEncoding { .. })
));
}
#[test]
fn verify_fails_on_wrong_length() {
let short_sig = "sha256=44a4e5e2";
assert_eq!(
verify_signature(short_sig, SECRET, PAYLOAD),
Err(WebhookVerificationError::InvalidLength {
expected: 32,
actual: 4
})
);
}
#[test]
fn verify_rfc4231_vector() {
let key = b"Jefe";
let data = b"what do ya want for nothing?";
let sig = "sha256=5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843";
assert!(verify_signature(sig, key, data).is_ok());
}
}