use core::fmt;
use crate::crypto::constant_time::constant_time_eq;
use crate::crypto::zeroize::Zeroizing;
use crate::crypto::{HmacSha256, Sha256};
use crate::encoding::{hex_decode, hex_encode};
use crate::util::log::{debug, trace, warn};
use crate::util::timestamp::Timestamp;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HmacAuthErrorKind {
InvalidSignature,
SignatureMismatch,
TimestampExpired,
InvalidTimestamp,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HmacAuthError {
kind: HmacAuthErrorKind,
}
impl HmacAuthError {
const fn new(kind: HmacAuthErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub fn is_invalid_signature(&self) -> bool {
self.kind == HmacAuthErrorKind::InvalidSignature
}
#[must_use]
pub fn is_signature_mismatch(&self) -> bool {
self.kind == HmacAuthErrorKind::SignatureMismatch
}
#[must_use]
pub fn is_timestamp_expired(&self) -> bool {
self.kind == HmacAuthErrorKind::TimestampExpired
}
#[must_use]
pub fn is_invalid_timestamp(&self) -> bool {
self.kind == HmacAuthErrorKind::InvalidTimestamp
}
}
impl fmt::Display for HmacAuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.kind {
HmacAuthErrorKind::InvalidSignature => {
write!(f, "hmac_auth: signature is not valid hex")
}
HmacAuthErrorKind::SignatureMismatch => {
write!(f, "hmac_auth: signature does not match")
}
HmacAuthErrorKind::TimestampExpired => {
write!(f, "hmac_auth: request timestamp has expired")
}
HmacAuthErrorKind::InvalidTimestamp => {
write!(f, "hmac_auth: timestamp is not a valid integer")
}
}
}
}
impl std::error::Error for HmacAuthError {}
fn build_canonical_string(method: &str, path: &str, timestamp: &str, body: &[u8]) -> String {
let body_hash = Sha256::digest(body);
let body_hash_hex = hex_encode(&body_hash);
format!("{method}\n{path}\n{timestamp}\n{body_hash_hex}")
}
pub struct HmacRequestSigner {
secret: Zeroizing<Vec<u8>>,
}
impl HmacRequestSigner {
#[must_use]
pub fn new(secret: Vec<u8>) -> Self {
Self {
secret: Zeroizing::new(secret),
}
}
#[must_use]
pub fn sign(&self, method: &str, path: &str, timestamp: &str, body: &[u8]) -> String {
let canonical = build_canonical_string(method, path, timestamp, body);
trace!(method, path, "hmac_auth: canonical string computed");
let mac = HmacSha256::mac(&self.secret, canonical.as_bytes());
let signature = hex_encode(&mac);
debug!(method, path, "hmac_auth: signed request");
signature
}
}
impl fmt::Debug for HmacRequestSigner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HmacRequestSigner")
.field("secret", &"[REDACTED]")
.finish_non_exhaustive()
}
}
pub struct HmacRequestVerifier {
secret: Zeroizing<Vec<u8>>,
max_age_secs: Option<u64>,
}
impl HmacRequestVerifier {
#[must_use]
pub fn new(secret: Vec<u8>) -> Self {
Self {
secret: Zeroizing::new(secret),
max_age_secs: None,
}
}
#[must_use]
pub fn with_max_age(mut self, max_age_secs: u64) -> Self {
self.max_age_secs = Some(max_age_secs);
self
}
pub fn verify(
&self,
method: &str,
path: &str,
timestamp: &str,
body: &[u8],
signature: &str,
) -> Result<(), HmacAuthError> {
let sig_bytes = hex_decode(signature).map_err(|_| {
warn!(method, path, "hmac_auth: verification failed: invalid hex");
HmacAuthError::new(HmacAuthErrorKind::InvalidSignature)
})?;
let canonical = build_canonical_string(method, path, timestamp, body);
let expected = HmacSha256::mac(&self.secret, canonical.as_bytes());
if !constant_time_eq(&sig_bytes, &expected) {
warn!(
method,
path, "hmac_auth: verification failed: signature mismatch"
);
return Err(HmacAuthError::new(HmacAuthErrorKind::SignatureMismatch));
}
if let Some(max_age) = self.max_age_secs {
let request_secs: u64 = timestamp.parse().map_err(|_| {
warn!(
method,
path, "hmac_auth: verification failed: invalid timestamp"
);
HmacAuthError::new(HmacAuthErrorKind::InvalidTimestamp)
})?;
let now = Timestamp::now().unix_epoch_secs();
let age = now.abs_diff(request_secs);
if age > max_age {
warn!(method, path, "hmac_auth: request timestamp expired");
return Err(HmacAuthError::new(HmacAuthErrorKind::TimestampExpired));
}
}
debug!(method, path, "hmac_auth: request verified");
Ok(())
}
}
impl fmt::Debug for HmacRequestVerifier {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HmacRequestVerifier")
.field("secret", &"[REDACTED]")
.field("max_age_secs", &self.max_age_secs)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_secret() -> Vec<u8> {
b"test-shared-secret-key-for-hmac".to_vec()
}
#[test]
fn sign_and_verify_round_trip() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let method = "POST";
let path = "/api/v1/users";
let timestamp = "1700000000";
let body = b"hello world";
let signature = signer.sign(method, path, timestamp, body);
verifier
.verify(method, path, timestamp, body, &signature)
.expect("valid signature should verify");
}
#[test]
fn sign_and_verify_empty_body() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let signature = signer.sign("GET", "/health", "1700000000", b"");
verifier
.verify("GET", "/health", "1700000000", b"", &signature)
.expect("empty body should verify");
}
#[test]
fn signature_is_deterministic() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret);
let sig1 = signer.sign("POST", "/api", "1700000000", b"data");
let sig2 = signer.sign("POST", "/api", "1700000000", b"data");
assert_eq!(sig1, sig2, "same inputs should produce the same signature");
}
#[test]
fn signature_is_hex_encoded() {
let signer = HmacRequestSigner::new(test_secret());
let sig = signer.sign("GET", "/", "0", b"");
assert_eq!(sig.len(), 64, "signature should be 64 hex chars");
assert!(
sig.chars().all(|c| c.is_ascii_hexdigit()),
"signature should be valid hex: {sig}",
);
}
#[test]
fn tampered_body_fails() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let signature = signer.sign("POST", "/api", "1700000000", b"original body");
let err = verifier
.verify("POST", "/api", "1700000000", b"tampered body", &signature)
.unwrap_err();
assert!(
err.is_signature_mismatch(),
"tampered body should produce SignatureMismatch",
);
}
#[test]
fn tampered_path_fails() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let signature = signer.sign("POST", "/api/v1/users", "1700000000", b"body");
let err = verifier
.verify("POST", "/api/v1/admin", "1700000000", b"body", &signature)
.unwrap_err();
assert!(
err.is_signature_mismatch(),
"tampered path should produce SignatureMismatch",
);
}
#[test]
fn tampered_method_fails() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let signature = signer.sign("POST", "/api", "1700000000", b"body");
let err = verifier
.verify("DELETE", "/api", "1700000000", b"body", &signature)
.unwrap_err();
assert!(err.is_signature_mismatch());
}
#[test]
fn tampered_timestamp_fails() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret);
let signature = signer.sign("POST", "/api", "1700000000", b"body");
let err = verifier
.verify("POST", "/api", "1700000001", b"body", &signature)
.unwrap_err();
assert!(err.is_signature_mismatch());
}
#[test]
fn wrong_secret_fails() {
let signer = HmacRequestSigner::new(b"secret-A".to_vec());
let verifier = HmacRequestVerifier::new(b"secret-B".to_vec());
let signature = signer.sign("GET", "/", "1700000000", b"");
let err = verifier
.verify("GET", "/", "1700000000", b"", &signature)
.unwrap_err();
assert!(
err.is_signature_mismatch(),
"wrong secret should produce SignatureMismatch",
);
}
#[test]
fn invalid_hex_signature_rejected() {
let verifier = HmacRequestVerifier::new(test_secret());
let err = verifier
.verify("GET", "/", "1700000000", b"", "not-valid-hex!!!")
.unwrap_err();
assert!(
err.is_invalid_signature(),
"non-hex signature should produce InvalidSignature",
);
}
#[test]
fn expired_timestamp_rejected() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret).with_max_age(300);
let signature = signer.sign("GET", "/", "1000000000", b"");
let err = verifier
.verify("GET", "/", "1000000000", b"", &signature)
.unwrap_err();
assert!(
err.is_timestamp_expired(),
"stale timestamp should produce TimestampExpired",
);
}
#[test]
fn recent_timestamp_accepted_with_max_age() {
let secret = test_secret();
let signer = HmacRequestSigner::new(secret.clone());
let verifier = HmacRequestVerifier::new(secret).with_max_age(300);
let now = Timestamp::now().unix_epoch_secs().to_string();
let signature = signer.sign("GET", "/", &now, b"");
verifier
.verify("GET", "/", &now, b"", &signature)
.expect("current timestamp should be accepted");
}
#[test]
fn invalid_timestamp_string_rejected() {
let signer = HmacRequestSigner::new(test_secret());
let verifier = HmacRequestVerifier::new(test_secret()).with_max_age(300);
let sig = signer.sign("GET", "/", "not-a-number", b"");
let err = verifier
.verify("GET", "/", "not-a-number", b"", &sig)
.unwrap_err();
assert!(
err.is_invalid_timestamp(),
"non-numeric timestamp should produce InvalidTimestamp",
);
}
#[test]
fn signature_checked_before_timestamp() {
let verifier = HmacRequestVerifier::new(test_secret()).with_max_age(300);
let bogus_sig = "aa".repeat(32);
for ts in ["not-a-number", "0", "9999999999"] {
let err = verifier
.verify("GET", "/", ts, b"", &bogus_sig)
.unwrap_err();
assert!(
err.is_signature_mismatch(),
"ts={ts}: bad signature must be rejected before timestamp checks",
);
}
}
#[test]
fn signer_debug_redacts_secret() {
let signer = HmacRequestSigner::new(b"super-secret".to_vec());
let debug = format!("{signer:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
#[test]
fn verifier_debug_redacts_secret() {
let verifier = HmacRequestVerifier::new(b"super-secret".to_vec());
let debug = format!("{verifier:?}");
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("super-secret"));
}
#[test]
fn error_display_messages() {
let cases = [
(
HmacAuthError::new(HmacAuthErrorKind::InvalidSignature),
"hmac_auth: signature is not valid hex",
),
(
HmacAuthError::new(HmacAuthErrorKind::SignatureMismatch),
"hmac_auth: signature does not match",
),
(
HmacAuthError::new(HmacAuthErrorKind::TimestampExpired),
"hmac_auth: request timestamp has expired",
),
(
HmacAuthError::new(HmacAuthErrorKind::InvalidTimestamp),
"hmac_auth: timestamp is not a valid integer",
),
];
for (err, expected) in &cases {
assert_eq!(err.to_string(), *expected);
}
}
#[test]
fn error_implements_std_error() {
let err: Box<dyn std::error::Error> =
Box::new(HmacAuthError::new(HmacAuthErrorKind::SignatureMismatch));
let _ = err.to_string();
}
}