Skip to main content

ocre/
webhooks.rs

1//! Webhooks: signatures for calls in both directions, and processing each
2//! event once.
3//!
4//! A payment provider, a GPU service or a mail service calls the app back
5//! with a signed POST. The handler checks the signature over the raw body,
6//! then runs the effect inside [`once`], which records the event in the
7//! `webhook_events` table (`ocre g webhook` creates it) and skips a
8//! delivery it has already processed: providers retry until they get a 2xx,
9//! so the same event often arrives twice.
10//!
11//! ```no_run
12//! use axum::{body::Bytes, extract::State, http::HeaderMap};
13//! use ocre::{Ctx, Result, webhooks};
14//!
15//! # #[allow(dead_code)]
16//!
17//! async fn receive(State(ctx): State<Ctx>, headers: HeaderMap, body: Bytes) -> Result<&'static str> {
18//!     let secret = ctx.secret("PAYMENTS_WEBHOOK_SECRET").await?;
19//!     let signature = headers.get("x-signature").and_then(|v| v.to_str().ok()).unwrap_or_default();
20//!     webhooks::verify(secret.as_bytes(), &body, signature)?;
21//!     let event: serde_json::Value =
22//!         serde_json::from_slice(&body).map_err(|_| ocre::Error::bad_request("invalid JSON"))?;
23//!     let id = event["id"].as_str().unwrap_or_default().to_owned();
24//!     let db = ctx.db()?;
25//!     webhooks::once(&db, "payments", &id, &body, || async {
26//!         // ... mark the order paid: runs once per event id
27//!         Ok(())
28//!     })
29//!     .await?;
30//!     Ok("ok")
31//! }
32//! ```
33//!
34//! [`sign`] is the other direction: the app signs what it sends (a job
35//! submitted to an external service), and the service checks it the same way.
36
37use base64::Engine as _;
38use hmac::{Hmac, Mac};
39use sha2::Sha256;
40
41use crate::{Error, Result};
42
43pub use crate::runtime::webhooks::{Answer, Delivery, STALE_AFTER, once, post_signed};
44
45/// The `webhook_events` table [`once`] records deliveries in, created by
46/// the migration of `ocre g webhook`.
47pub const TABLE_SQL: &str = "CREATE TABLE webhook_events (
48  id INTEGER PRIMARY KEY,
49  source TEXT NOT NULL,
50  event_id TEXT NOT NULL,
51  payload TEXT NOT NULL,
52  status TEXT NOT NULL,
53  attempts INTEGER NOT NULL DEFAULT 1,
54  error TEXT,
55  received_at INTEGER NOT NULL,
56  processed_at INTEGER,
57  UNIQUE (source, event_id)
58);
59";
60
61/// HMAC-SHA256 of `message` with `secret`, as lowercase hex: the signature
62/// to send in a header (`X-Signature`) of an outgoing call.
63///
64/// # Examples
65///
66/// ```
67/// let signature = ocre::webhooks::sign(b"secret", b"{\"id\":1}");
68/// assert_eq!(signature.len(), 64);
69/// assert!(ocre::webhooks::verify(b"secret", b"{\"id\":1}", &signature).is_ok());
70/// ```
71pub fn sign(secret: &[u8], message: &[u8]) -> String {
72    let mut mac = mac(secret);
73    mac.update(message);
74    let bytes = mac.finalize().into_bytes();
75    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
76}
77
78/// Checks that `signature` is the HMAC-SHA256 of `message` with `secret`,
79/// in constant time. The signature may be hex (any case, optionally
80/// prefixed `sha256=` as GitHub sends it) or base64 (standard or URL-safe,
81/// padded or not), the common encodings of webhook providers. Sign the raw
82/// request body: parsed and re-serialized JSON may differ by a space.
83///
84/// # Errors
85///
86/// [`Error::Unauthorized`] when the signature is missing, malformed or
87/// does not match.
88///
89/// # Examples
90///
91/// ```
92/// use base64::Engine as _;
93/// use ocre::webhooks::{sign, verify};
94///
95/// let hex = sign(b"secret", b"body");
96/// assert!(verify(b"secret", b"body", &format!("sha256={hex}")).is_ok());
97/// assert!(verify(b"other", b"body", &hex).is_err());
98/// ```
99pub fn verify(secret: &[u8], message: &[u8], signature: &str) -> Result<()> {
100    let signature = signature.trim();
101    let signature = signature.strip_prefix("sha256=").unwrap_or(signature);
102    let expected = decode_hex(signature).or_else(|| decode_base64(signature)).ok_or(Error::Unauthorized)?;
103    let mut mac = mac(secret);
104    mac.update(message);
105    mac.verify_slice(&expected).map_err(|_| Error::Unauthorized)
106}
107
108/// Checks a [Standard Webhooks](https://www.standardwebhooks.com) delivery
109/// (Svix, Resend, and other providers): `webhook-signature` holds one or
110/// more space-separated `v1,<base64>` signatures of `{id}.{timestamp}.{body}`
111/// keyed with the base64 part of the `whsec_...` secret, and the
112/// `webhook-timestamp` must be within `tolerance` seconds of `now` (replays
113/// of an old delivery are refused). Returns the `webhook-id`, the event id
114/// to give [`once`].
115///
116/// # Errors
117///
118/// [`Error::Unauthorized`] when a header is missing, the timestamp is out
119/// of tolerance, or no signature matches; [`Error::Internal`] when the
120/// secret is not `whsec_` followed by base64.
121///
122/// # Examples
123///
124/// ```
125/// use axum::http::HeaderMap;
126/// use base64::Engine as _;
127/// use ocre::webhooks::{sign, verify_standard};
128///
129/// let key = b"0123456789abcdef";
130/// let secret = format!("whsec_{}", base64::engine::general_purpose::STANDARD.encode(key));
131/// let signature = sign(key, b"msg_1.1700000000.{}");
132/// let bytes: Vec<u8> = (0..32).map(|i| u8::from_str_radix(&signature[i * 2..i * 2 + 2], 16).unwrap()).collect();
133/// let mut headers = HeaderMap::new();
134/// headers.insert("webhook-id", "msg_1".parse().unwrap());
135/// headers.insert("webhook-timestamp", "1700000000".parse().unwrap());
136/// let value = format!("v1,{}", base64::engine::general_purpose::STANDARD.encode(bytes));
137/// headers.insert("webhook-signature", value.parse().unwrap());
138/// assert_eq!(verify_standard(&secret, &headers, b"{}", 300, 1_700_000_100).unwrap(), "msg_1");
139/// assert!(verify_standard(&secret, &headers, b"{}", 300, 1_700_001_000).is_err(), "too old");
140/// ```
141pub fn verify_standard(
142    secret: &str,
143    headers: &axum::http::HeaderMap,
144    body: &[u8],
145    tolerance: i64,
146    now: i64,
147) -> Result<String> {
148    let key = standard_key(secret)?;
149    let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok()).ok_or(Error::Unauthorized);
150    let id = header("webhook-id")?;
151    let timestamp = header("webhook-timestamp")?;
152    let sent_at: i64 = timestamp.parse().map_err(|_| Error::Unauthorized)?;
153    if (now - sent_at).abs() > tolerance {
154        return Err(Error::Unauthorized);
155    }
156    let message = [id.as_bytes(), b".", timestamp.as_bytes(), b".", body].concat();
157    let signed = header("webhook-signature")?
158        .split_whitespace()
159        .filter_map(|entry| entry.strip_prefix("v1,"))
160        .any(|signature| verify(&key, &message, signature).is_ok());
161    if signed { Ok(id.to_owned()) } else { Err(Error::Unauthorized) }
162}
163
164fn mac(secret: &[u8]) -> Hmac<Sha256> {
165    Hmac::<Sha256>::new_from_slice(secret).expect("HMAC takes keys of any length")
166}
167
168fn decode_hex(text: &str) -> Option<Vec<u8>> {
169    if !text.len().is_multiple_of(2) || !text.bytes().all(|byte| byte.is_ascii_hexdigit()) {
170        return None;
171    }
172    (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).ok()).collect()
173}
174
175/// The `webhook-signature` header value (`v1,<base64>`) of a [Standard
176/// Webhooks](https://www.standardwebhooks.com) delivery of `body` with this
177/// `id` and `timestamp` (Unix seconds): what [`verify_standard`] checks.
178/// For an app that sends Standard Webhooks, and for tests of one that
179/// receives them.
180///
181/// # Errors
182///
183/// [`Error::Internal`] when the secret is not `whsec_` followed by base64.
184///
185/// # Examples
186///
187/// ```
188/// use axum::http::HeaderMap;
189/// use ocre::webhooks::{sign_standard, verify_standard};
190///
191/// let secret = "whsec_c2VjcmV0";
192/// let mut headers = HeaderMap::new();
193/// headers.insert("webhook-id", "msg_1".parse().unwrap());
194/// headers.insert("webhook-timestamp", "1700000000".parse().unwrap());
195/// let signature = sign_standard(secret, "msg_1", 1_700_000_000, b"{}").unwrap();
196/// headers.insert("webhook-signature", signature.parse().unwrap());
197/// assert!(verify_standard(secret, &headers, b"{}", 300, 1_700_000_000).is_ok());
198/// ```
199pub fn sign_standard(secret: &str, id: &str, timestamp: i64, body: &[u8]) -> Result<String> {
200    let key = standard_key(secret)?;
201    let mut mac = mac(&key);
202    mac.update(&[id.as_bytes(), b".", timestamp.to_string().as_bytes(), b".", body].concat());
203    Ok(format!("v1,{}", base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())))
204}
205
206/// The HMAC key of a `whsec_<base64>` secret.
207fn standard_key(secret: &str) -> Result<Vec<u8>> {
208    secret
209        .strip_prefix("whsec_")
210        .and_then(decode_base64)
211        .ok_or_else(|| Error::internal("webhooks: a Standard Webhooks secret is `whsec_` followed by base64"))
212}
213
214fn decode_base64(text: &str) -> Option<Vec<u8>> {
215    use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
216    [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD].iter().find_map(|engine| engine.decode(text).ok())
217}
218
219#[cfg(test)]
220#[path = "../tests/webhooks.rs"]
221mod tests;