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