[dependencies]hmac = "=0.13.0"sha2 = "=0.11.1"hex = "=0.4.3" use hmac::{Hmac, KeyInit, Mac};use sha2::Sha256; type HmacSha256 = Hmac<Sha256>; /// Sender: HMAC-SHA256 over the exact body bytes, sent as "sha256=<hex>".pub fn sign(secret: &[u8], raw_body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC takes keys of any length"); mac.update(raw_body); format!("sha256={}", hex::encode(mac.finalize().into_bytes()))} /// Receiver: recompute over the raw body; verify_slice compares in constant time.pub fn verify(secret: &[u8], raw_body: &[u8], header: Option<&str>) -> bool { let Some(received) = header .and_then(|value| value.strip_prefix("sha256=")) .and_then(|hex_mac| hex::decode(hex_mac).ok()) else { return false; // a missing or malformed header fails, it never skips the check }; let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC takes keys of any length"); mac.update(raw_body); mac.verify_slice(&received).is_ok() // Err for a wrong MAC and for a wrong length} How it works
HmacSha256::new_from_slice(secret)creates the MAC state (HMAC accepts keys of any length, hence theexpect);updatefeeds the body andfinalize().into_bytes()returns the 32 bytes.verify_slice(&received)finalizes the MAC and compares it with the received bytes in constant time, returningErrfor a mismatch and for a slice of the wrong length. Comparinginto_bytes()with==would compare plain arrays with an early exit;verify_slice(like theCtOutputthatfinalize()returns) does not.verifyturns every malformed input — no header, nosha256=prefix, invalid hex — intofalsewith onelet … else, so no branch can accidentally accept an unsigned request.- The run checks GitHub's example:
Hello, World!with the secretIt's a Secret to Everybodygivessha256=757107ea…3e17, while a tampered body, a wrong secret and a truncated header are rejected.
Gotchas
hex::decoderefuses an odd number of digits (Err(OddLength)) and any non-hex character; treat every decoding error as an invalid signature.hex::decodeaccepts upper-case digits — the run verifiessha256=757107EA…— so normalising the header's case is unnecessary when you compare bytes, not strings.- A truncated header decodes to fewer than 32 bytes, and
verify_slicerejects it (truncated header: false) instead of comparing a prefix; do not slice the computed MAC to the received length to make them "match".