#![allow(clippy::missing_panics_doc)]
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use hmac::{Hmac, KeyInit, Mac};
use http::{HeaderMap, HeaderName, HeaderValue};
use sha2::Sha256;
use cratefield_core::{ProviderScheme, SignatureEncoding};
type HmacSha256 = Hmac<Sha256>;
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
fn hmac_sha256(key: &[u8], payload: &[u8]) -> Vec<u8> {
let mut mac =
<HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(payload);
mac.finalize().into_bytes().to_vec()
}
fn header_map(name: &str, value: &str) -> HeaderMap {
let mut map = HeaderMap::new();
map.insert(
HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
HeaderValue::from_str(value).expect("valid header value"),
);
map
}
#[must_use]
pub fn sign_stripe_style(header: &str, secret: &str, t: i64, body: &[u8]) -> HeaderMap {
let digest = hmac_sha256(
secret.trim().as_bytes(),
&[t.to_string().as_bytes(), b".", body].concat(),
);
header_map(header, &format!("t={t},v1={}", hex_encode(&digest)))
}
#[must_use]
pub fn sign_provider_scheme(
scheme: &ProviderScheme,
secret: &str,
t: i64,
body: &[u8],
) -> HeaderMap {
let payload = if scheme.timestamp.is_some() {
[t.to_string().as_bytes(), b".", body].concat()
} else {
body.to_vec()
};
let digest = hmac_sha256(secret.trim().as_bytes(), &payload);
let encoded = match scheme.encoding {
SignatureEncoding::Hex => hex_encode(&digest),
SignatureEncoding::Base64 => STANDARD.encode(&digest),
};
let mut map = header_map(
scheme.signature,
&format!("{}{encoded}", scheme.prefix.unwrap_or("")),
);
if let Some(name) = scheme.timestamp {
map.insert(
HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
HeaderValue::from_str(&t.to_string()).expect("valid header value"),
);
}
map
}
#[cfg(test)]
mod tests {
use super::*;
use cratefield_core::{DEFAULT_TOLERANCE_SECS, StripeStyle, WebhookVerifier};
const SECRET: &str = "whsec_endpoint-secret-dummy";
const NOW: i64 = 1_800_000_000;
#[test]
fn a_stripe_style_signature_verifies() {
let body = br#"{"type":"checkout.session.completed"}"#;
let got = sign_stripe_style("Stripe-Signature", SECRET, NOW, body);
assert!(WebhookVerifier::new(StripeStyle::default()).verify(SECRET, &got, body, NOW));
let renamed = sign_stripe_style("X-Signature", SECRET, NOW, body);
let layout = StripeStyle {
header: "X-Signature",
};
assert!(WebhookVerifier::new(layout).verify(SECRET, &renamed, body, NOW));
}
#[test]
fn a_stripe_style_signature_refuses_a_tampered_body_wrong_secret_or_stale_time() {
let body = b"{}";
let got = sign_stripe_style("Stripe-Signature", SECRET, NOW, body);
let verifier = WebhookVerifier::new(StripeStyle::default());
assert!(!verifier.verify(SECRET, &got, b"x", NOW));
assert!(!verifier.verify("another-secret-dummy", &got, body, NOW));
let stale = NOW - DEFAULT_TOLERANCE_SECS - 1;
assert!(!verifier.verify(SECRET, &got, body, stale));
assert!(!verifier.verify("", &got, body, NOW));
}
#[test]
fn a_hex_prefixed_provider_signature_verifies() {
let body = br#"{"event":"order.created"}"#;
let scheme = ProviderScheme {
signature: "X-Signature",
encoding: SignatureEncoding::Hex,
prefix: Some("sha256="),
timestamp: Some("X-At"),
};
let got = sign_provider_scheme(&scheme, SECRET, NOW, body);
assert!(
got["X-Signature"]
.to_str()
.expect("hex header is utf-8")
.starts_with("sha256=")
);
assert_eq!(
got["X-At"].to_str().expect("timestamp is utf-8"),
NOW.to_string()
);
assert!(WebhookVerifier::new(scheme).verify(SECRET, &got, body, NOW));
assert!(!WebhookVerifier::new(scheme).verify(SECRET, &got, b"x", NOW));
assert!(!WebhookVerifier::new(scheme).verify("other-secret-dummy", &got, body, NOW));
let stale = NOW - DEFAULT_TOLERANCE_SECS - 1;
assert!(!WebhookVerifier::new(scheme).verify(SECRET, &got, body, stale));
}
#[test]
fn a_base64_timestamp_free_provider_signature_verifies() {
let body = br#"{"event":"order.created"}"#;
let scheme = ProviderScheme {
signature: "X-Signature",
encoding: SignatureEncoding::Base64,
prefix: None,
timestamp: None,
};
let got = sign_provider_scheme(&scheme, SECRET, NOW, body);
assert_eq!(got.len(), 1, "only the signature header is set");
assert!(WebhookVerifier::new(scheme).verify(SECRET, &got, body, NOW));
assert!(WebhookVerifier::new(scheme).verify(SECRET, &got, body, NOW + 10_000));
assert!(!WebhookVerifier::new(scheme).verify(SECRET, &got, b"x", NOW));
assert!(!WebhookVerifier::new(scheme).verify("other-secret-dummy", &got, body, NOW));
}
}