From ab01a62bd1874194ba77cf276b65c82cea154425 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 23:12:31 +0000 Subject: [PATCH] huskies: merge 808 --- Cargo.lock | 2 + Cargo.toml | 2 + server/Cargo.toml | 2 + .../chat/transport/whatsapp/commands/mod.rs | 1 + server/src/chat/transport/whatsapp/mod.rs | 50 ++++++ server/src/chat/transport/whatsapp/verify.rs | 161 +++++++++++++++++- server/src/startup/bots.rs | 1 + 7 files changed, 214 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b70f9046..48969ff1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2317,6 +2317,7 @@ version = "0.10.4" dependencies = [ "async-stream", "async-trait", + "base64", "bft-json-crdt", "bytes", "chrono", @@ -2348,6 +2349,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_yaml", + "sha1", "sha2 0.11.0", "source-map-gen", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index c04f5117..6b32b1ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,9 +22,11 @@ rust-embed = "8" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" +sha1 = "0.10" sha2 = "0.11.0" hmac = "0.13" subtle = "2" +base64 = "0.22" serde_yaml = "0.9" strip-ansi-escapes = "0.2" tempfile = "3" diff --git a/server/Cargo.toml b/server/Cargo.toml index c643acec..ef99fd59 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -24,9 +24,11 @@ rust-embed = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_urlencoded = { workspace = true } +sha1 = { workspace = true } sha2 = { workspace = true } hmac = { workspace = true } subtle = { workspace = true } +base64 = { workspace = true } serde_yaml = { workspace = true } strip-ansi-escapes = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "process"] } diff --git a/server/src/chat/transport/whatsapp/commands/mod.rs b/server/src/chat/transport/whatsapp/commands/mod.rs index a4b9bfd2..3106aa16 100644 --- a/server/src/chat/transport/whatsapp/commands/mod.rs +++ b/server/src/chat/transport/whatsapp/commands/mod.rs @@ -321,6 +321,7 @@ mod tests { window_tracker: tracker, allowed_phones, app_secret: String::new(), + twilio_auth_token: String::new(), }) } diff --git a/server/src/chat/transport/whatsapp/mod.rs b/server/src/chat/transport/whatsapp/mod.rs index 00dd7ba8..2976ef98 100644 --- a/server/src/chat/transport/whatsapp/mod.rs +++ b/server/src/chat/transport/whatsapp/mod.rs @@ -7,11 +7,17 @@ //! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp //! webhook (GET verification handshake + POST incoming messages). +/// Incoming message command dispatch (e.g. `!status`, `!help`). pub mod commands; +/// WhatsApp message formatting helpers. pub mod format; +/// Conversation history and 24-hour messaging-window tracking. pub mod history; +/// Meta Graph API transport implementation. pub mod meta; +/// Twilio REST API transport implementation. pub mod twilio; +/// HMAC signature verification for Meta and Twilio webhooks. pub mod verify; pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history}; @@ -35,17 +41,20 @@ pub struct WebhookPayload { pub entry: Vec, } +/// One entry in the top-level `entry` array of a Meta webhook payload. #[derive(Deserialize, Debug)] pub struct WebhookEntry { #[serde(default)] pub changes: Vec, } +/// One change event within a [`WebhookEntry`]. #[derive(Deserialize, Debug)] pub struct WebhookChange { pub value: Option, } +/// The `value` object inside a [`WebhookChange`], containing messages and metadata. #[derive(Deserialize, Debug)] pub struct WebhookValue { #[serde(default)] @@ -54,12 +63,14 @@ pub struct WebhookValue { pub metadata: Option, } +/// Phone-number metadata attached to a [`WebhookValue`]. #[derive(Deserialize, Debug)] pub struct WebhookMetadata { #[allow(dead_code)] pub phone_number_id: Option, } +/// A single inbound WhatsApp message from the Meta webhook. #[derive(Deserialize, Debug)] pub struct WebhookMessage { pub from: Option, @@ -67,6 +78,7 @@ pub struct WebhookMessage { pub text: Option, } +/// The `text` body of a [`WebhookMessage`]. #[derive(Deserialize, Debug)] pub struct WebhookText { pub body: Option, @@ -126,6 +138,10 @@ pub struct WhatsAppWebhookContext { /// When non-empty, every inbound POST is verified against this secret. /// When empty, signature verification is skipped. pub app_secret: String, + /// Twilio Auth Token for `X-Twilio-Signature` HMAC-SHA1 verification. + /// When non-empty, every inbound Twilio POST is verified. + /// When empty, Twilio signature verification is skipped. + pub twilio_auth_token: String, } /// GET /webhook/whatsapp — webhook verification. @@ -200,6 +216,24 @@ pub async fn webhook_receive( } } + // Verify HMAC-SHA1 X-Twilio-Signature for Twilio webhooks. + if ctx.provider == "twilio" && !ctx.twilio_auth_token.is_empty() { + let signature = req.header("X-Twilio-Signature").unwrap_or(""); + if signature.is_empty() { + slog!("[whatsapp/twilio] Missing X-Twilio-Signature header; rejecting request"); + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Missing X-Twilio-Signature"); + } + let url = reconstruct_request_url(req); + if !verify::verify_twilio_signature(&ctx.twilio_auth_token, &url, &bytes, signature) { + slog!("[whatsapp/twilio] X-Twilio-Signature verification failed; rejecting request"); + return Response::builder() + .status(StatusCode::FORBIDDEN) + .body("Invalid signature"); + } + } + let messages = if ctx.provider == "twilio" { let msgs = extract_twilio_text_messages(&bytes); if msgs.is_empty() { @@ -238,6 +272,22 @@ pub async fn webhook_receive( Response::builder().status(StatusCode::OK).body("ok") } +/// Reconstruct the full public URL of a webhook request. +/// +/// Uses `X-Forwarded-Proto` (or falls back to `"https"`) and `Host` to build +/// the scheme + authority, then appends the request path and any query string. +/// This matches the URL Twilio used when it signed the request. +fn reconstruct_request_url(req: &Request) -> String { + let scheme = req.header("x-forwarded-proto").unwrap_or("https"); + let host = req.header("host").unwrap_or(""); + let uri = req.uri(); + let path = uri.path(); + match uri.query() { + Some(q) => format!("{scheme}://{host}{path}?{q}"), + None => format!("{scheme}://{host}{path}"), + } +} + // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/server/src/chat/transport/whatsapp/verify.rs b/server/src/chat/transport/whatsapp/verify.rs index cd834346..1dfa3850 100644 --- a/server/src/chat/transport/whatsapp/verify.rs +++ b/server/src/chat/transport/whatsapp/verify.rs @@ -1,13 +1,18 @@ -//! HMAC-SHA256 signature verification for Meta WhatsApp webhooks. +//! HMAC signature verification for Meta and Twilio WhatsApp webhooks. //! -//! Meta signs every inbound webhook request with the app secret and sends -//! the result as `X-Hub-Signature-256: sha256=`. This module -//! verifies that signature using the `hmac` + `sha2` crates and a -//! constant-time comparison from `subtle`. +//! - **Meta** signs every inbound request with the app secret and sends the +//! result as `X-Hub-Signature-256: sha256=` (HMAC-SHA256). +//! - **Twilio** signs every inbound request with the auth token and sends the +//! result as `X-Twilio-Signature: ` (HMAC-SHA1). +//! +//! Both paths use a constant-time comparison from `subtle` to prevent timing +//! attacks. use std::fmt::Write as FmtWrite; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use hmac::{Hmac, KeyInit, Mac}; +use sha1::Digest as Sha1Digest; use sha2::Sha256; use subtle::ConstantTimeEq; @@ -43,6 +48,94 @@ pub(super) fn verify_meta_signature(app_secret: &str, body: &[u8], signature: &s expected_hex.as_bytes().ct_eq(hex_sig.as_bytes()).into() } +/// Verify the `X-Twilio-Signature` header sent by Twilio. +/// +/// Twilio's algorithm (see Twilio docs — "Validating Requests from Twilio"): +/// 1. Start with the full URL of the request. +/// 2. Sort POST parameters alphabetically and append each `key + value` +/// (no delimiters) to the URL string. +/// 3. Compute `HMAC-SHA1(auth_token, signed_string)`. +/// 4. Base64-encode the result. +/// 5. Compare with the `X-Twilio-Signature` header value using constant-time +/// comparison. +/// +/// Returns `true` if the signature is valid, `false` otherwise (including +/// when the header value is empty or the body cannot be decoded). +pub(super) fn verify_twilio_signature( + auth_token: &str, + url: &str, + form_body: &[u8], + signature: &str, +) -> bool { + let signed = build_twilio_signed_string(url, form_body); + let computed = hmac_sha1(auth_token.as_bytes(), signed.as_bytes()); + let computed_b64 = BASE64_STANDARD.encode(computed.as_slice()); + + // Constant-time comparison to prevent timing attacks. + computed_b64.as_bytes().ct_eq(signature.as_bytes()).into() +} + +/// HMAC-SHA1 using the `sha1 = "0.10"` audited crate as the hash primitive. +/// +/// `sha1 = "0.10"` uses `digest 0.10`, which is incompatible with +/// `hmac = "0.13"` (digest 0.11), so the HMAC construction (ipad/opad +/// per RFC 2104) is done here using sha1's `Digest` trait directly. +fn hmac_sha1(key: &[u8], message: &[u8]) -> [u8; 20] { + const BLOCK: usize = 64; + + // Shorten key to its SHA-1 hash if it exceeds the block size. + let key_hash: [u8; 20]; + let key = if key.len() > BLOCK { + key_hash = sha1::Sha1::digest(key).into(); + &key_hash[..] + } else { + key + }; + + // Pad key to block size. + let mut k = [0u8; BLOCK]; + k[..key.len()].copy_from_slice(key); + + // Inner hash: SHA1(k ^ ipad || message) + let mut ipad = k; + for b in &mut ipad { + *b ^= 0x36; + } + let mut h = sha1::Sha1::new(); + Sha1Digest::update(&mut h, ipad); + Sha1Digest::update(&mut h, message); + let inner: [u8; 20] = h.finalize().into(); + + // Outer hash: SHA1(k ^ opad || inner) + let mut opad = k; + for b in &mut opad { + *b ^= 0x5c; + } + let mut h = sha1::Sha1::new(); + Sha1Digest::update(&mut h, opad); + Sha1Digest::update(&mut h, inner); + h.finalize().into() +} + +/// Build the string that Twilio signs for a POST webhook. +/// +/// Format: `url` followed by each sorted POST parameter's `key + value` +/// (decoded, no delimiters between pairs). +fn build_twilio_signed_string(url: &str, form_body: &[u8]) -> String { + // Parse form-encoded body into key-value pairs (already percent-decoded). + let mut params: Vec<(String, String)> = + serde_urlencoded::from_bytes(form_body).unwrap_or_default(); + // Sort alphabetically by key (Unix case-sensitive order). + params.sort_by(|(a, _), (b, _)| a.cmp(b)); + + let mut s = url.to_string(); + for (k, v) in ¶ms { + s.push_str(k); + s.push_str(v); + } + s +} + // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] @@ -99,4 +192,62 @@ mod tests { let sig = make_signature("correct-secret", body); assert!(!verify_meta_signature("wrong-secret", body, &sig)); } + + // ── Twilio HMAC-SHA1 tests ───────────────────────────────────────── + + /// Compute a Twilio reference signature for tests. + fn make_twilio_signature(auth_token: &str, url: &str, form_body: &[u8]) -> String { + let signed = build_twilio_signed_string(url, form_body); + let result = hmac_sha1(auth_token.as_bytes(), signed.as_bytes()); + BASE64_STANDARD.encode(result.as_slice()) + } + + const TEST_URL: &str = "https://example.com/webhook/whatsapp"; + const TEST_TOKEN: &str = "twilio-auth-token"; + + #[test] + fn twilio_valid_signature_passes() { + let body = b"Body=hello+world&From=whatsapp%3A%2B15551234567"; + let sig = make_twilio_signature(TEST_TOKEN, TEST_URL, body); + assert!(verify_twilio_signature(TEST_TOKEN, TEST_URL, body, &sig)); + } + + #[test] + fn twilio_tampered_body_is_rejected() { + let body = b"Body=hello+world&From=whatsapp%3A%2B15551234567"; + let sig = make_twilio_signature(TEST_TOKEN, TEST_URL, body); + let tampered = b"Body=injected&From=whatsapp%3A%2B15551234567"; + assert!(!verify_twilio_signature( + TEST_TOKEN, TEST_URL, tampered, &sig + )); + } + + #[test] + fn twilio_missing_signature_is_rejected() { + let body = b"Body=hello&From=whatsapp%3A%2B15551234567"; + // Empty string simulates a missing header. + assert!(!verify_twilio_signature(TEST_TOKEN, TEST_URL, body, "")); + } + + #[test] + fn twilio_wrong_token_is_rejected() { + let body = b"Body=hello&From=whatsapp%3A%2B15551234567"; + let sig = make_twilio_signature("correct-token", TEST_URL, body); + assert!(!verify_twilio_signature( + "wrong-token", + TEST_URL, + body, + &sig + )); + } + + #[test] + fn twilio_params_sorted_alphabetically() { + // Body < From — order in the raw body should not affect the signed string. + let body_bf = b"Body=hi&From=whatsapp%3A%2B1"; + let body_fb = b"From=whatsapp%3A%2B1&Body=hi"; + let sig = make_twilio_signature(TEST_TOKEN, TEST_URL, body_bf); + // Both orderings of the same parameters must produce identical signatures. + assert!(verify_twilio_signature(TEST_TOKEN, TEST_URL, body_fb, &sig)); + } } diff --git a/server/src/startup/bots.rs b/server/src/startup/bots.rs index c2ae4a68..6a9b13a4 100644 --- a/server/src/startup/bots.rs +++ b/server/src/startup/bots.rs @@ -71,6 +71,7 @@ pub(crate) fn build_bot_contexts( window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()), allowed_phones: cfg.whatsapp_allowed_phones.clone(), app_secret: cfg.whatsapp_app_secret.clone().unwrap_or_default(), + twilio_auth_token: cfg.twilio_auth_token.clone().unwrap_or_default(), }) });