Skip to main content

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}