allowthem_core/
webhook_sig.rs1use hmac::{Hmac, Mac};
14use sha2::Sha256;
15use subtle::ConstantTimeEq;
16
17type HmacSha256 = Hmac<Sha256>;
18
19#[derive(Debug, thiserror::Error)]
21pub enum SigError {
22 #[error("malformed signature header")]
24 Malformed,
25 #[error("signature mismatch")]
27 Mismatch,
28 #[error("signature timestamp out of tolerance")]
30 Stale,
31}
32
33pub fn sign_payload(secret: &[u8], timestamp: i64, body: &[u8]) -> String {
41 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
42 mac.update(format!("{timestamp}.").as_bytes());
43 mac.update(body);
44 let tag = mac.finalize().into_bytes();
45 format!("t={timestamp},v1={}", hex::encode(tag))
46}
47
48pub fn verify_payload(
56 secret: &[u8],
57 body: &[u8],
58 signature_header: &str,
59 now_unix_seconds: i64,
60 tolerance_seconds: i64,
61) -> Result<(), SigError> {
62 let mut ts_part: Option<i64> = None;
64 let mut sig_hex: Option<String> = None;
65 for part in signature_header.split(',') {
66 if let Some(v) = part.strip_prefix("t=") {
67 ts_part = v.parse().ok();
68 } else if let Some(v) = part.strip_prefix("v1=") {
69 sig_hex = Some(v.to_owned());
70 }
71 }
72 let timestamp = ts_part.ok_or(SigError::Malformed)?;
73 let sig_bytes =
74 hex::decode(sig_hex.ok_or(SigError::Malformed)?).map_err(|_| SigError::Malformed)?;
75
76 if tolerance_seconds > 0 {
78 let age = now_unix_seconds.saturating_sub(timestamp);
79 if age > tolerance_seconds {
80 return Err(SigError::Stale);
81 }
82 }
83
84 let expected = sign_payload(secret, timestamp, body);
86 let expected_hex = expected.split("v1=").nth(1).ok_or(SigError::Malformed)?;
88 let expected_bytes = hex::decode(expected_hex).map_err(|_| SigError::Malformed)?;
89
90 if sig_bytes.ct_eq(&expected_bytes).into() {
91 Ok(())
92 } else {
93 Err(SigError::Mismatch)
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 const SECRET: &[u8] = b"test-secret";
102 const TS: i64 = 1_700_000_000;
103 const BODY: &[u8] = b"{}";
104
105 #[test]
106 fn golden_value() {
107 assert_eq!(
114 sign_payload(SECRET, TS, BODY),
115 "t=1700000000,v1=87d3ed18b9b403e7da0fc3a3ae8b9394303805a049ea06f87c2ef4380b521fa9"
116 );
117 }
118
119 #[test]
120 fn round_trip() {
121 let sig = sign_payload(SECRET, TS, BODY);
122 assert!(verify_payload(SECRET, BODY, &sig, TS, 0).is_ok());
123 }
124
125 #[test]
126 fn tampered_body_fails() {
127 let sig = sign_payload(SECRET, TS, BODY);
128 let tampered = b"{\"x\":1}";
129 assert!(matches!(
130 verify_payload(SECRET, tampered, &sig, TS, 0),
131 Err(SigError::Mismatch)
132 ));
133 }
134
135 #[test]
136 fn tampered_secret_fails() {
137 let sig = sign_payload(b"key-a", TS, BODY);
138 assert!(matches!(
139 verify_payload(b"key-b", BODY, &sig, TS, 0),
140 Err(SigError::Mismatch)
141 ));
142 }
143
144 #[test]
145 fn stale_timestamp() {
146 let old_ts = TS - 600;
147 let sig = sign_payload(SECRET, old_ts, BODY);
148 assert!(matches!(
149 verify_payload(SECRET, BODY, &sig, TS, 300),
150 Err(SigError::Stale)
151 ));
152 }
153
154 #[test]
155 fn malformed_header_no_t() {
156 let sig = sign_payload(SECRET, TS, BODY);
158 let v1_only = sig.split(',').find(|p| p.starts_with("v1=")).unwrap();
159 assert!(matches!(
160 verify_payload(SECRET, BODY, v1_only, TS, 0),
161 Err(SigError::Malformed)
162 ));
163 }
164}