use hmac::{Hmac, Mac};
use sha2::Sha256;
pub const HMAC_ALGORITHM: &str = "hmac-sha256";
pub const SIGNATURE_HEADER: &str = "x-hub-signature-256";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignatureVerdict {
Valid,
SecretMissing,
Invalid,
}
impl SignatureVerdict {
pub fn is_valid(self) -> bool {
matches!(self, SignatureVerdict::Valid)
}
}
pub fn verify_github_signature(
secret: &str,
body: &[u8],
signature_header: &str,
) -> SignatureVerdict {
if secret.trim().is_empty() {
return SignatureVerdict::SecretMissing;
}
let Some(hex_sig) = signature_header.strip_prefix("sha256=") else {
return SignatureVerdict::Invalid;
};
let Ok(expected) = hex::decode(hex_sig) else {
return SignatureVerdict::Invalid;
};
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return SignatureVerdict::Invalid;
};
mac.update(body);
if mac.verify_slice(&expected).is_ok() {
SignatureVerdict::Valid
} else {
SignatureVerdict::Invalid
}
}
pub fn sign_github_body(secret: &str, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC-SHA256 accepts a key of any length");
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "test-hmac-key"; const BODY: &[u8] = br#"{"action":"review_requested","number":7}"#;
#[test]
fn verify_github_signature_accepts_a_valid_digest() {
let header = sign_github_body(SECRET, BODY);
assert_eq!(
verify_github_signature(SECRET, BODY, &header),
SignatureVerdict::Valid
);
}
#[test]
fn verify_github_signature_rejects_a_wrong_secret() {
let header = sign_github_body("some-other-secret", BODY); assert_eq!(
verify_github_signature(SECRET, BODY, &header),
SignatureVerdict::Invalid
);
}
#[test]
fn verify_github_signature_reports_a_missing_secret() {
let header = sign_github_body(SECRET, BODY);
for empty in ["", " ", "\t\n"] {
assert_eq!(
verify_github_signature(empty, BODY, &header),
SignatureVerdict::SecretMissing,
"secret {empty:?}"
);
assert!(!verify_github_signature(empty, BODY, &header).is_valid());
}
}
#[test]
fn verify_github_signature_rejects_a_missing_prefix() {
let signed = sign_github_body(SECRET, BODY);
let bare = signed.trim_start_matches("sha256=");
assert_eq!(
verify_github_signature(SECRET, BODY, bare),
SignatureVerdict::Invalid
);
assert_eq!(
verify_github_signature(SECRET, BODY, ""),
SignatureVerdict::Invalid
);
}
#[test]
fn verify_github_signature_rejects_non_hex() {
assert_eq!(
verify_github_signature(SECRET, BODY, "sha256=zzzz"),
SignatureVerdict::Invalid
);
}
#[test]
fn verify_github_signature_rejects_a_truncated_digest() {
let signed = sign_github_body(SECRET, BODY);
let truncated = &signed[..signed.len() - 4];
assert_eq!(
verify_github_signature(SECRET, BODY, truncated),
SignatureVerdict::Invalid
);
}
#[test]
fn verify_github_signature_rejects_a_one_byte_body_change() {
let header = sign_github_body(SECRET, BODY);
let mut mutated = BODY.to_vec();
let last = mutated.len() - 1;
mutated[last] ^= 0x01;
assert_eq!(
verify_github_signature(SECRET, &mutated, &header),
SignatureVerdict::Invalid
);
}
}