#![forbid(unsafe_code)]
#![warn(missing_docs)]
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
pub const SIGNATURE_HEADER: &str = "X-Tango-Signature";
pub const SIGNATURE_PREFIX: &str = "sha256=";
#[must_use]
pub fn generate(body: &[u8], secret: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC-SHA256 accepts keys of any length");
mac.update(body);
let digest = mac.finalize().into_bytes();
let mut out = String::with_capacity(SIGNATURE_PREFIX.len() + digest.len() * 2);
out.push_str(SIGNATURE_PREFIX);
out.push_str(&hex::encode(digest));
out
}
#[must_use]
pub fn verify(body: &[u8], header: &str, secret: &str) -> bool {
let Some(parsed) = parse(header) else {
return false;
};
if parsed.algorithm != "sha256" {
return false;
}
let expected_full = generate(body, secret);
let Some(expected_hex) = expected_full.strip_prefix(SIGNATURE_PREFIX) else {
return false;
};
if expected_hex.len() != parsed.signature.len() {
return false;
}
let Ok(expected_bytes) = hex::decode(expected_hex) else {
return false;
};
let Ok(actual_bytes) = hex::decode(&parsed.signature) else {
return false;
};
expected_bytes.ct_eq(&actual_bytes).into()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedSignature {
pub algorithm: String,
pub signature: String,
}
#[must_use]
pub fn parse(header: &str) -> Option<ParsedSignature> {
let stripped = header.trim();
if stripped.is_empty() {
return None;
}
let (alg, sig) = match stripped.find('=') {
Some(0) => return None, Some(i) => (&stripped[..i], &stripped[i + 1..]),
None => ("sha256", stripped),
};
if sig.is_empty() || !is_hex(sig) {
return None;
}
Some(ParsedSignature {
algorithm: alg.to_ascii_lowercase(),
signature: sig.to_ascii_lowercase(),
})
}
fn is_hex(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
const KNOWN_VECTOR: &str =
"sha256=0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70";
#[test]
fn generate_matches_known_vector() {
assert_eq!(generate(b"hello", "shh"), KNOWN_VECTOR);
}
#[test]
fn generate_is_deterministic() {
assert_eq!(generate(b"hello", "shh"), generate(b"hello", "shh"));
}
#[test]
fn verify_roundtrip() {
let body = br#"{"event":"contract.updated","id":"123"}"#;
let header = generate(body, "topsecret");
assert!(verify(body, &header, "topsecret"));
}
#[test]
fn verify_rejects_wrong_secret() {
let body = b"payload";
let header = generate(body, "right");
assert!(!verify(body, &header, "wrong"));
}
#[test]
fn verify_rejects_tampered_body() {
let body = b"original";
let header = generate(body, "secret");
assert!(!verify(b"tampered", &header, "secret"));
}
#[test]
fn verify_rejects_empty_header() {
assert!(!verify(b"body", "", "secret"));
assert!(!verify(b"body", " ", "secret"));
}
#[test]
fn verify_rejects_malformed_headers() {
let body = b"body";
for h in [
"",
" ",
"sha256=",
"sha256=zzz",
"md5=abc",
"not-hex",
"=abc",
"sha256=0e39", ] {
assert!(
!verify(body, h, "secret"),
"verify unexpectedly accepted malformed header {h:?}",
);
}
}
#[test]
fn verify_accepts_bare_hex_legacy() {
let body = b"payload";
let with_prefix = generate(body, "s");
let bare = with_prefix
.strip_prefix(SIGNATURE_PREFIX)
.expect("generate always emits the prefix");
assert!(verify(body, bare, "s"));
}
#[test]
fn verify_is_case_insensitive_on_hex() {
let body = b"payload";
let header = generate(body, "secret");
let upper = header.to_uppercase();
assert!(verify(body, &upper, "secret"));
}
#[test]
fn parse_accepts_canonical_form() {
let p = parse(KNOWN_VECTOR).expect("canonical form parses");
assert_eq!(p.algorithm, "sha256");
assert_eq!(
p.signature,
"0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70",
);
}
#[test]
fn parse_accepts_bare_hex_form() {
let bare = "0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70";
let p = parse(bare).expect("bare hex parses");
assert_eq!(p.algorithm, "sha256");
assert_eq!(p.signature, bare);
}
#[test]
fn parse_normalizes_case() {
let upper = "SHA256=0E396369EE043C5B6B922743631745B2249CF7CB2C4722E61E802447D5D14C70";
let p = parse(upper).expect("uppercase form parses");
assert_eq!(p.algorithm, "sha256");
assert_eq!(
p.signature,
"0e396369ee043c5b6b922743631745b2249cf7cb2c4722e61e802447d5d14c70",
);
}
#[test]
fn parse_trims_whitespace() {
let p = parse(" sha256=deadbeef ").expect("padded form parses");
assert_eq!(p.algorithm, "sha256");
assert_eq!(p.signature, "deadbeef");
}
#[test]
fn parse_rejects_empty_and_whitespace() {
assert!(parse("").is_none());
assert!(parse(" ").is_none());
}
#[test]
fn parse_rejects_empty_signature() {
assert!(parse("sha256=").is_none());
}
#[test]
fn parse_rejects_empty_algorithm_prefix() {
assert!(parse("=deadbeef").is_none());
}
#[test]
fn parse_rejects_non_hex_signature() {
assert!(parse("sha256=zzzz").is_none());
assert!(parse("sha256=dead beef").is_none());
assert!(parse("not-hex").is_none());
}
#[test]
fn parse_preserves_non_sha256_algorithm() {
let p = parse("md5=deadbeef").expect("any alg parses if hex");
assert_eq!(p.algorithm, "md5");
assert!(!verify(b"body", "md5=deadbeef", "secret"));
}
#[test]
fn parsed_signature_is_clonable_and_comparable() {
let a = parse(KNOWN_VECTOR).unwrap();
let b = a.clone();
assert_eq!(a, b);
}
}