Webhooks
Check that a webhook came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender, with timestamp tolerance; and sign your own.
Version 1.0.0 · 2 functions · 3 crates
Functions
What use webhooks.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use webhook_verify, webhook_sign from webhooks (Quillo 0.6).
webhook_verify(provider: str, body: str, headers: map, secret: str, tolerance_seconds: int) → boolor an errorChecks a webhook: provider is "stripe", "github", "slack", "shopify" or "standard"; body the raw request body (request.body); headers the request's headers; secret the endpoint's signing secret. Stripe, Slack and Standard Webhooks carry a timestamp, refused when more than tolerance_seconds from now (300 is usual; 0 skips that check). Gives true, or an error value saying what is wrong.
webhook_sign(provider: str, body: str, secret: str, timestamp: int, id: str) → mapor an errorThe headers to send with a webhook of your own, signed as provider signs them, for body at timestamp (Unix seconds; 0 for now). For tests of a receiver, and for apps that send webhooks; "standard" also takes id from a webhook-id-style message id (empty: one made from the time).
Get it
$ quillo install webhooks # webhooks.qwer into this folder$ quillo install webhooks --example # and its example, webhooks.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/webhooks.qwer$ curl -O https://quillolang.org/plugins/files/webhooks.qweThen, in your program, all of it or only what you call:
use webhooks.qweruse webhook_verify, webhook_sign from webhooks // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
hmac = "0.12"sha2 = "0.10"base64 = "0.22"Example
// Receiving Stripe and GitHub webhooks, refusing any that were not signed with the secret.//// quillo run webhooks.qwe then open http://localhost:8080/selftest//// Point Stripe at POST /hooks/stripe and GitHub at POST /hooks/github.use "webhooks.qwer"let STRIPE_SECRET = env("STRIPE_WEBHOOK_SECRET") ?? "whsec_test_quillo"let GITHUB_SECRET = env("GITHUB_WEBHOOK_SECRET") ?? "quillo-github-secret"route POST /hooks/stripe { let ok = webhook_verify("stripe", request.body, request.headers, STRIPE_SECRET, 300) if is_err(ok) { reply 400 ok.error } { received: request.json.type }}route POST /hooks/github { let ok = webhook_verify("github", request.body, request.headers, GITHUB_SECRET, 0) if is_err(ok) { reply 401 ok.error } { received: request.headers["x-github-event"] ?? "ping" }}// What a sender's signature looks like, and the checks on it.route GET /selftest { let body = json.stringify({ type: "invoice.paid", data: { id: "in_42" } }) let stripe = webhook_sign("stripe", body, STRIPE_SECRET, 0, "")? let old = webhook_sign("stripe", body, STRIPE_SECRET, int(now() / 1000) - 3600, "")? // GitHub's header is HMAC-SHA256 in hex: Quillo's own crypto.hmac makes the same one. let github = { "x-hub-signature-256": `sha256=${crypto.hmac(GITHUB_SECRET, body)}` } { stripe: webhook_verify("stripe", body, stripe, STRIPE_SECRET, 300)?, stripe_changed_body: webhook_verify("stripe", replace(body, "42", "43"), stripe, STRIPE_SECRET, 300).error, stripe_replayed: webhook_verify("stripe", body, old, STRIPE_SECRET, 300).error, github: webhook_verify("github", body, github, GITHUB_SECRET, 0)?, shopify: webhook_verify("shopify", body, webhook_sign("shopify", body, "shpss_1", 0, "")?, "shpss_1", 0)?, }}$ quillo run webhooks.qwe$ curl 'localhost:8080/selftest'Source
//! Check that a webhook really came from Stripe, GitHub, Slack, Shopify or a Standard Webhooks sender (Svix, Resend, Clerk), with the timestamp's tolerance; and sign your own.//! version: 1.0.0//!//! [dependencies]//! hmac = "0.12"//! sha2 = "0.10"//! base64 = "0.22"use base64::engine::general_purpose::STANDARD;use base64::Engine;use hmac::{Hmac, Mac};use sha2::Sha256;use std::collections::HashMap;type HmacSha256 = Hmac<Sha256>;fn now() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)}fn mac(key: &[u8], parts: &[&[u8]]) -> HmacSha256 { let mut m = HmacSha256::new_from_slice(key).expect("HMAC takes any key length"); for p in parts { m.update(p); } m}fn hex(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{:02x}", b)).collect()}fn unhex(s: &str) -> Option<Vec<u8>> { if s.len() % 2 != 0 { return None; } (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()}fn header<'a>(headers: &'a HashMap<String, String>, name: &str) -> Result<&'a str, String> { headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str()).ok_or_else(|| format!("no `{}` header", name))}fn fresh(ts: &str, tolerance: i64) -> Result<i64, String> { let t: i64 = ts.trim().parse().map_err(|_| format!("the timestamp `{}` is not a number", ts))?; if tolerance > 0 && (now() - t).abs() > tolerance { return Err(format!("the timestamp is {} s away from now, more than the {} s allowed: a replay, or a clock that is off", (now() - t).abs(), tolerance)); } Ok(t)}fn standard_key(secret: &str) -> Result<Vec<u8>, String> { STANDARD.decode(secret.trim().trim_start_matches("whsec_")).map_err(|_| "a Standard Webhooks secret is `whsec_` and base64".to_string())}const NO_MATCH: &str = "the signature does not match: a wrong secret, or the body was changed (check it with the raw body, not one parsed and written again)";/// Checks a webhook: `provider` is "stripe", "github", "slack", "shopify" or "standard"; `body` the raw request body/// (`request.body`); `headers` the request's headers; `secret` the endpoint's signing secret. Stripe, Slack and Standard Webhooks/// carry a timestamp, refused when more than `tolerance_seconds` from now (300 is usual; 0 skips that check)./// Gives true, or an error value saying what is wrong.pub fn webhook_verify(provider: &str, body: &str, headers: std::collections::HashMap<String, String>, secret: &str, tolerance_seconds: i64) -> Result<bool, String> { let b = body.as_bytes(); let ok = match provider.to_lowercase().as_str() { "stripe" => { let h = header(&headers, "stripe-signature")?; let mut t = None; let mut sigs = vec![]; for part in h.split(',') { match part.trim().split_once('=') { Some(("t", v)) => t = Some(v.to_string()), Some(("v1", v)) => sigs.push(v.to_string()), _ => {} } } let t = t.ok_or("the Stripe-Signature header has no t=")?; fresh(&t, tolerance_seconds)?; sigs.iter().filter_map(|s| unhex(s)).any(|s| mac(secret.as_bytes(), &[t.as_bytes(), b".", b]).verify_slice(&s).is_ok()) } "github" => { let h = header(&headers, "x-hub-signature-256")?; let s = h.trim().strip_prefix("sha256=").ok_or("X-Hub-Signature-256 starts with sha256=")?; unhex(s).is_some_and(|s| mac(secret.as_bytes(), &[b]).verify_slice(&s).is_ok()) } "slack" => { let t = header(&headers, "x-slack-request-timestamp")?; fresh(t, tolerance_seconds)?; let s = header(&headers, "x-slack-signature")?; let s = s.trim().strip_prefix("v0=").ok_or("X-Slack-Signature starts with v0=")?; unhex(s).is_some_and(|s| mac(secret.as_bytes(), &[b"v0:", t.trim().as_bytes(), b":", b]).verify_slice(&s).is_ok()) } "shopify" => { let s = header(&headers, "x-shopify-hmac-sha256")?; STANDARD.decode(s.trim()).ok().is_some_and(|s| mac(secret.as_bytes(), &[b]).verify_slice(&s).is_ok()) } "standard" => { let id = header(&headers, "webhook-id")?; let t = header(&headers, "webhook-timestamp")?; fresh(t, tolerance_seconds)?; let key = standard_key(secret)?; let sigs = header(&headers, "webhook-signature")?; sigs.split_whitespace().filter_map(|s| s.strip_prefix("v1,")).filter_map(|s| STANDARD.decode(s).ok()) .any(|s| mac(&key, &[id.as_bytes(), b".", t.trim().as_bytes(), b".", b]).verify_slice(&s).is_ok()) } other => return Err(format!("unknown provider `{}`: stripe, github, slack, shopify or standard", other)), }; if ok { Ok(true) } else { Err(NO_MATCH.into()) }}/// The headers to send with a webhook of your own, signed as `provider` signs them, for `body` at `timestamp`/// (Unix seconds; 0 for now). For tests of a receiver, and for apps that send webhooks; "standard" also takes `id`/// from a `webhook-id`-style message id (empty: one made from the time).pub fn webhook_sign(provider: &str, body: &str, secret: &str, timestamp: i64, id: &str) -> Result<HashMap<String, String>, String> { let t = if timestamp > 0 { timestamp } else { now() }; let ts = t.to_string(); let b = body.as_bytes(); let sign = |parts: &[&[u8]]| mac(secret.as_bytes(), parts).finalize().into_bytes(); Ok(match provider.to_lowercase().as_str() { "stripe" => HashMap::from([("stripe-signature".into(), format!("t={},v1={}", ts, hex(&sign(&[ts.as_bytes(), b".", b]))))]), "github" => HashMap::from([("x-hub-signature-256".into(), format!("sha256={}", hex(&sign(&[b]))))]), "slack" => HashMap::from([ ("x-slack-request-timestamp".into(), ts.clone()), ("x-slack-signature".into(), format!("v0={}", hex(&sign(&[b"v0:", ts.as_bytes(), b":", b])))), ]), "shopify" => HashMap::from([("x-shopify-hmac-sha256".into(), STANDARD.encode(sign(&[b])))]), "standard" => { let id = if id.is_empty() { format!("msg_{}", t) } else { id.to_string() }; let key = standard_key(secret)?; let s = mac(&key, &[id.as_bytes(), b".", ts.as_bytes(), b".", b]).finalize().into_bytes(); HashMap::from([ ("webhook-id".into(), id), ("webhook-timestamp".into(), ts), ("webhook-signature".into(), format!("v1,{}", STANDARD.encode(s))), ]) } other => return Err(format!("unknown provider `{}`: stripe, github, slack, shopify or standard", other)), })}Related
signatures.qwer · 1.0.0Ed25519 key pairs, text signed with the private key and checked with the public one: exports, webhooks and messages between servers.7 views · 7 downloadsBiscuit tokens biscuit.qwer · 1.0.0Biscuit authorization tokens: issued with Datalog facts and checks signed by your key, narrowed by their holder without asking the issuer, and authorized offline against each request.7 views · 8 downloadsLicence keys license_keys.qwer · 1.0.0Signed licence keys with a customer, features and an expiry date: made with your private key, checked offline, impossible to forge.6 views · 6 downloadsRSA rsa.qwer · 1.0.0RSA key pairs as PEM, PKCS#1 v1.5 SHA-256 signatures for partner and bank APIs, and OAEP encryption of short secrets.9 views · 8 downloadsStripe stripe.qwer · 1.0.0Stripe's API without an SDK: Checkout sessions and PaymentIntents, any endpoint called with nested form encoding and idempotency keys, amounts in each currency's minor units, and webhook events verified and read.7 views · 6 downloadsCanonical JSON json_canonical.qwer · 1.0.0Canonical JSON (RFC 8785): one way to write a value, keys sorted and numbers as JavaScript writes them, so hashes and signatures of JSON hold however it was spaced or ordered.7 views · 5 downloadsThread 0 posts
On Webhooks: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.