1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
use super::*;
impl WebhookError {
/// Return the reason as a stable diagnostic string.
///
/// # Returns
///
/// - `&'static str` - the message a log line should carry.
pub const fn message(&self) -> &'static str {
match self {
WebhookError::MissingSignature => MESSAGE_NO_SIGNATURE,
WebhookError::TimestampOutOfTolerance => MESSAGE_TIMESTAMP_OUT_OF_TOLERANCE,
WebhookError::EmptySecret => MESSAGE_EMPTY_SECRET,
WebhookError::SignatureMismatch => MESSAGE_SIGNATURE_MISMATCH,
}
}
}
impl Display for WebhookError {
/// Render the diagnostic.
///
/// # Arguments
///
/// - `&mut Formatter<'_>` - the formatter to write the value into.
///
/// # Returns
///
/// A `Formatter` carrying the diagnostic message.
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.message())
}
}
impl WebhookEvent {
/// Build a verification target from the three webhook inputs.
///
/// The signature is the bare hex digest, not the whole
/// `Stripe-Signature` header; `parse_signature_header` extracts it.
///
/// # Arguments
///
/// - `String` - the exact request body Stripe signed.
/// - `i64` - the timestamp Stripe signed, in seconds.
/// - `String` - the hex digest Stripe sent.
///
/// # Returns
///
/// - `Self` - the assembled verification target.
pub fn new(payload: String, timestamp: i64, signature: String) -> Self {
let mut built: Self = Self {
payload: String::new(),
timestamp: 0,
signature: String::new(),
};
built.set_payload(payload);
built.set_timestamp(timestamp);
built.set_signature(signature);
built
}
/// Replace the signed payload during construction.
///
/// # Arguments
///
/// - `String` - the request body Stripe signed.
fn set_payload(&mut self, payload: String) {
self.payload = payload;
}
/// Replace the signed timestamp.
///
/// # Arguments
///
/// - `i64` - seconds since the Unix epoch.
fn set_timestamp(&mut self, timestamp: i64) {
self.timestamp = timestamp;
}
/// Replace the expected digest.
///
/// # Arguments
///
/// - `String` - the hex digest without the `v1=` prefix.
fn set_signature(&mut self, signature: String) {
self.signature = signature;
}
/// Sign the payload the way Stripe does.
///
/// Stripe signs the timestamp and the payload joined by a dot,
/// then HMAC-SHA256 hex-encodes the digest, so this builds that
/// exact signed text before hashing.
///
/// # Arguments
///
/// - `&str` - the endpoint's webhook signing secret.
///
/// # Returns
///
/// - `Result<String, WebhookError>` - the hex digest Stripe would
/// have sent, or an error when the secret is empty.
pub fn compute_signature(&self, secret: &str) -> Result<String, WebhookError> {
if secret.is_empty() {
return Err(WebhookError::EmptySecret);
}
let signed: String = format!("{}.{}", self.get_timestamp(), self.get_payload());
let mut mac: Hmac<Sha256> = match Hmac::<Sha256>::new_from_slice(secret.as_bytes()) {
Ok(created) => created,
Err(_invalid_length) => return Err(WebhookError::EmptySecret),
};
mac.update(signed.as_bytes());
Ok(hex::encode(mac.finalize().into_bytes()))
}
/// Return whether the timestamp sits within `tolerance` of `now`.
///
/// Stripe recommends rejecting anything outside a five-minute
/// window so a captured request cannot be replayed later.
///
/// # Arguments
///
/// - `i64` - the current time in seconds since the epoch.
/// - `i64` - the accepted drift, in seconds.
///
/// # Returns
///
/// - `bool` - `true` when the timestamp is inside the window.
pub fn is_timestamp_fresh(&self, now: i64, tolerance: i64) -> bool {
let drift: i64 = now - self.get_timestamp();
if drift < 0 {
-drift <= tolerance
} else {
drift <= tolerance
}
}
/// Verify the digest against the secret.
///
/// # Arguments
///
/// - `&str` - the endpoint's webhook signing secret.
///
/// # Returns
///
/// - `Result<(), WebhookError>` - `Ok(())` when the digest matches.
pub fn verify_signature(&self, secret: &str) -> Result<(), WebhookError> {
let expected: String = self.compute_signature(secret)?;
if Self::constant_time_eq(expected.as_bytes(), self.get_signature().as_bytes()) {
return Ok(());
}
Err(WebhookError::SignatureMismatch)
}
/// Verify freshness and then the digest.
///
/// # Arguments
///
/// - `&str` - the endpoint's webhook signing secret.
/// - `i64` - the current time in seconds since the epoch.
/// - `i64` - the accepted drift, in seconds.
///
/// # Returns
///
/// - `Result<(), WebhookError>` - `Ok(())` when the event is genuine.
pub fn verify(&self, secret: &str, now: i64, tolerance: i64) -> Result<(), WebhookError> {
if !self.is_timestamp_fresh(now, tolerance) {
return Err(WebhookError::TimestampOutOfTolerance);
}
self.verify_signature(secret)
}
/// Split a `Stripe-Signature` header into its timestamp and digest.
///
/// Stripe sends comma-separated entries such as
/// `t=1614556800,v1=deadbeef,v0=ignored`. The `v0` entry is the
/// legacy scheme and `v1` is the current one, so only `v1` is
/// accepted; a header carrying no `v1` entry is rejected rather
/// than downgraded.
///
/// # Arguments
///
/// - `&str` - the raw `Stripe-Signature` header value.
/// - `&str` - the exact request body Stripe signed.
///
/// # Returns
///
/// - `Result<WebhookEvent, WebhookError>` - the verification
/// target, or `MissingSignature` when no timestamp or `v1` digest
/// was present.
pub fn parse_signature_header(
header: &str,
payload: &str,
) -> Result<WebhookEvent, WebhookError> {
let mut timestamp: Option<i64> = None;
let mut signature: Option<String> = None;
for entry in header.split(TIMESTAMP_SEPARATOR) {
let Some((key, value)) = entry.split_once(KEY_VALUE_SEPARATOR) else {
continue;
};
match key {
TIMESTAMP_FIELD => {
timestamp = value.parse::<i64>().ok();
}
SIGNATURE_SCHEME => {
signature = Some(String::from(value));
}
_ => {}
}
}
let resolved_timestamp: i64 = match timestamp {
Some(value) => value,
None => return Err(WebhookError::MissingSignature),
};
let resolved_signature: String = match signature {
Some(value) => value,
None => return Err(WebhookError::MissingSignature),
};
Ok(WebhookEvent::new(
String::from(payload),
resolved_timestamp,
resolved_signature,
))
}
/// Read the webhook signing secret from the process environment.
///
/// # Returns
///
/// - `Result<String, WebhookError>` - the configured secret, or
/// `EmptySecret` when the variable is unset or blank.
pub fn secret_from_env() -> Result<String, WebhookError> {
match std::env::var(SECRET_KEY) {
Ok(value) if !value.is_empty() => Ok(value),
_ => Err(WebhookError::EmptySecret),
}
}
/// Verify a raw webhook request end to end.
///
/// This is the entry point a hyperlane handler calls: it parses the
/// header, then checks freshness and the digest.
///
/// # Arguments
///
/// - `&str` - the raw `Stripe-Signature` header value.
/// - `&str` - the exact request body Stripe signed.
/// - `&str` - the endpoint's webhook signing secret.
/// - `i64` - the current time in seconds since the epoch.
/// - `i64` - the accepted drift, in seconds.
///
/// # Returns
///
/// - `Result<WebhookEvent, WebhookError>` - the verified event.
pub fn verify_webhook(
header: &str,
payload: &str,
secret: &str,
now: i64,
tolerance: i64,
) -> Result<WebhookEvent, WebhookError> {
let event: WebhookEvent = Self::parse_signature_header(header, payload)?;
event.verify(secret, now, tolerance)?;
Ok(event)
}
/// Compare two byte slices without leaking their contents through
/// timing.
///
/// A webhook digest is attacker-supplied, so an early-exit
/// comparison would leak how many leading bytes matched.
///
/// # Arguments
///
/// - `&[u8]` - the digest this crate computed.
/// - `&[u8]` - the digest the caller supplied.
///
/// # Returns
///
/// - `bool` - `true` when the two slices are equal.
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut difference: u8 = 0;
let mut index: usize = 0;
while index < left.len() {
difference |= left[index] ^ right[index];
index += 1;
}
difference == 0
}
}