use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use hmac::digest::KeyInit;
use hmac::{Hmac, Mac};
use secrecy::{ExposeSecret, SecretString};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::error::{OlError, OL_4220_HMAC_FAILED, OL_4221_MALFORMED_BODY, OL_4226_TIMESTAMP_SKEW};
pub const MAX_TIMESTAMP_SKEW_SECS: i64 = 300;
pub fn decode_secret(secret: &SecretString) -> Result<Vec<u8>, OlError> {
let exposed = secret.expose_secret();
let stripped = exposed.strip_prefix("whsec_").ok_or_else(|| {
OlError::new(
OL_4220_HMAC_FAILED,
"secret missing `whsec_` prefix (per Standard Webhooks v1)",
)
.with_suggestion(
"rotate the binding secret: `openlatch-provider bindings rotate-secret <id>`",
)
})?;
STANDARD
.decode(stripped)
.map_err(|e| OlError::new(OL_4220_HMAC_FAILED, format!("secret base64 decode: {e}")))
}
pub fn compute_signature(
key: &[u8],
webhook_id: &str,
webhook_timestamp: i64,
body: &[u8],
) -> String {
let mut mac =
<Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("Hmac<Sha256> accepts any key size");
mac.update(webhook_id.as_bytes());
mac.update(b".");
mac.update(webhook_timestamp.to_string().as_bytes());
mac.update(b".");
mac.update(body);
STANDARD.encode(mac.finalize().into_bytes())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VerifyFailure {
Hmac,
Timestamp,
MalformedHeader,
}
impl VerifyFailure {
pub fn telemetry_kind(self) -> &'static str {
match self {
VerifyFailure::Hmac => "hmac",
VerifyFailure::Timestamp => "timestamp",
VerifyFailure::MalformedHeader => "hmac",
}
}
}
pub fn verify(
secret: &SecretString,
webhook_id: &str,
webhook_timestamp: i64,
raw_body: &[u8],
signature_header: &str,
) -> Result<(), OlError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|e| OlError::new(OL_4221_MALFORMED_BODY, format!("system clock: {e}")))?;
let skew = (now - webhook_timestamp).abs();
if skew > MAX_TIMESTAMP_SKEW_SECS {
return Err(OlError::new(
OL_4226_TIMESTAMP_SKEW,
format!("timestamp skew {skew}s exceeds ±{MAX_TIMESTAMP_SKEW_SECS}s (sw v1 max)"),
));
}
if signature_header.trim().is_empty() {
return Err(OlError::new(
OL_4220_HMAC_FAILED,
"webhook-signature header is empty",
));
}
let key = decode_secret(secret)?;
let expected_b64 = compute_signature(&key, webhook_id, webhook_timestamp, raw_body);
let expected_bytes = expected_b64.as_bytes();
let mut saw_v1_entry = false;
for entry in signature_header.split_whitespace() {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if let Some(b64) = entry.strip_prefix("v1,") {
saw_v1_entry = true;
let candidate = b64.as_bytes();
if candidate.len() == expected_bytes.len()
&& bool::from(candidate.ct_eq(expected_bytes))
{
return Ok(());
}
}
}
if !saw_v1_entry {
return Err(OlError::new(
OL_4220_HMAC_FAILED,
"webhook-signature header carries no v1 entry",
));
}
Err(OlError::new(OL_4220_HMAC_FAILED, "HMAC signature mismatch"))
}
#[derive(Debug, Clone)]
pub struct SignedHeaders {
pub webhook_id: String,
pub webhook_timestamp: i64,
pub webhook_signature: String,
}
pub fn sign_response(secret: &SecretString, body: &[u8]) -> Result<SignedHeaders, OlError> {
let key = decode_secret(secret)?;
let webhook_id = format!("msg_{}", uuid::Uuid::now_v7().simple());
let webhook_timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|e| OlError::new(OL_4221_MALFORMED_BODY, format!("system clock: {e}")))?;
let sig_b64 = compute_signature(&key, &webhook_id, webhook_timestamp, body);
Ok(SignedHeaders {
webhook_id,
webhook_timestamp,
webhook_signature: format!("v1,{sig_b64}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn whsec(key_bytes: &[u8]) -> SecretString {
SecretString::from(format!("whsec_{}", STANDARD.encode(key_bytes)))
}
#[test]
fn decode_secret_strips_prefix_and_decodes_base64() {
let s = SecretString::from("whsec_aGVsbG8=".to_string()); let bytes = decode_secret(&s).unwrap();
assert_eq!(bytes, b"hello");
}
#[test]
fn decode_secret_rejects_missing_prefix() {
let s = SecretString::from("rawbytes".to_string());
let err = decode_secret(&s).unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn decode_secret_rejects_malformed_base64() {
let s = SecretString::from("whsec_!!!".to_string());
let err = decode_secret(&s).unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn verify_accepts_valid_signature() {
let secret = whsec(b"my-key");
let id = "msg_test";
let ts = now_secs();
let body = b"{\"event_id\":\"evt_x\"}";
let key_bytes = b"my-key";
let sig = compute_signature(key_bytes, id, ts, body);
let header = format!("v1,{sig}");
verify(&secret, id, ts, body, &header).unwrap();
}
#[test]
fn verify_rejects_tampered_body() {
let secret = whsec(b"my-key");
let id = "msg_test";
let ts = now_secs();
let body = b"original body";
let sig = compute_signature(b"my-key", id, ts, body);
let header = format!("v1,{sig}");
let err = verify(&secret, id, ts, b"tampered body", &header).unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn verify_rejects_wrong_secret() {
let actual = whsec(b"correct");
let _attacker = whsec(b"wrong");
let id = "msg_test";
let ts = now_secs();
let body = b"hello";
let sig = compute_signature(b"wrong", id, ts, body);
let header = format!("v1,{sig}");
let err = verify(&actual, id, ts, body, &header).unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn verify_rejects_stale_timestamp() {
let secret = whsec(b"my-key");
let id = "msg_test";
let ts = now_secs() - 600;
let body = b"hello";
let sig = compute_signature(b"my-key", id, ts, body);
let header = format!("v1,{sig}");
let err = verify(&secret, id, ts, body, &header).unwrap_err();
assert_eq!(err.code.code, "OL-4226");
}
#[test]
fn verify_rejects_future_timestamp_beyond_skew() {
let secret = whsec(b"my-key");
let id = "msg_test";
let ts = now_secs() + 600;
let body = b"hello";
let sig = compute_signature(b"my-key", id, ts, body);
let header = format!("v1,{sig}");
let err = verify(&secret, id, ts, body, &header).unwrap_err();
assert_eq!(err.code.code, "OL-4226");
}
#[test]
fn verify_accepts_multiple_space_delimited_entries_with_v1_match() {
let secret = whsec(b"new-key");
let id = "msg_test";
let ts = now_secs();
let body = b"hello";
let real = compute_signature(b"new-key", id, ts, body);
let bogus = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
let header = format!("v1,{bogus} v1,{real}");
verify(&secret, id, ts, body, &header).unwrap();
}
#[test]
fn verify_silently_skips_v1a_asymmetric_entries() {
let secret = whsec(b"my-key");
let id = "msg_test";
let ts = now_secs();
let body = b"hello";
let real = compute_signature(b"my-key", id, ts, body);
let header = format!("v1a,fakeAsymmetricSig v1,{real}");
verify(&secret, id, ts, body, &header).unwrap();
}
#[test]
fn verify_returns_4220_when_only_unknown_versions_present() {
let secret = whsec(b"my-key");
let err = verify(&secret, "id", now_secs(), b"x", "v1a,foo v2,bar").unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn verify_returns_4220_on_empty_header() {
let secret = whsec(b"my-key");
let err = verify(&secret, "id", now_secs(), b"x", "").unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
#[test]
fn sign_response_round_trips_via_verify() {
let secret = whsec(b"key");
let body = b"{\"riskScore\":5}";
let h = sign_response(&secret, body).unwrap();
verify(
&secret,
&h.webhook_id,
h.webhook_timestamp,
body,
&h.webhook_signature,
)
.unwrap();
assert!(h.webhook_id.starts_with("msg_"));
assert!(h.webhook_signature.starts_with("v1,"));
}
#[test]
fn cross_impl_test_vectors_round_trip() {
const FIXTURE: &str =
include_str!("../../tests/fixtures/standard_webhooks_test_vectors.json");
let parsed: serde_json::Value = serde_json::from_str(FIXTURE).expect("fixture parses");
let vectors = parsed["vectors"].as_array().expect("vectors[]");
assert!(!vectors.is_empty(), "fixture should not be empty");
for v in vectors {
let secret = SecretString::from(v["secret"].as_str().unwrap().to_string());
let webhook_id = v["webhook_id"].as_str().unwrap();
let ts = v["webhook_timestamp"].as_i64().unwrap();
let body = STANDARD
.decode(v["body_b64"].as_str().unwrap())
.expect("body_b64 decodes");
let expected = v["expected_v1_sig"].as_str().unwrap();
let key = decode_secret(&secret).expect("decode_secret");
let computed = compute_signature(&key, webhook_id, ts, &body);
assert_eq!(
computed,
expected,
"computed != expected for `{}`",
v["description"].as_str().unwrap_or("?")
);
let now = now_secs();
let live_sig = compute_signature(&key, webhook_id, now, &body);
verify(&secret, webhook_id, now, &body, &format!("v1,{live_sig}"))
.expect("verify() must accept a freshly-signed roundtrip");
}
}
#[test]
fn sign_response_flipped_byte_breaks_verify() {
let secret = whsec(b"key");
let body = b"{\"riskScore\":5}";
let h = sign_response(&secret, body).unwrap();
let mut tampered = body.to_vec();
tampered[0] ^= 0x01;
let err = verify(
&secret,
&h.webhook_id,
h.webhook_timestamp,
&tampered,
&h.webhook_signature,
)
.unwrap_err();
assert_eq!(err.code.code, "OL-4220");
}
}