From 97b9eaa39dfcfa3e27df6bcedba11d2a4c1985c9 Mon Sep 17 00:00:00 2001 From: dave Date: Tue, 28 Apr 2026 20:44:31 +0000 Subject: [PATCH] huskies: merge 807 --- Cargo.lock | 43 ++++++-- Cargo.toml | 2 + server/Cargo.toml | 2 + .../src/chat/transport/matrix/config/types.rs | 6 ++ .../chat/transport/whatsapp/commands/mod.rs | 1 + server/src/chat/transport/whatsapp/mod.rs | 27 ++++- server/src/chat/transport/whatsapp/verify.rs | 102 ++++++++++++++++++ server/src/startup/bots.rs | 1 + 8 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 server/src/chat/transport/whatsapp/verify.rs diff --git a/Cargo.lock b/Cargo.lock index 89810263..b70f9046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colored" version = "2.2.0" @@ -1073,6 +1079,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -1378,6 +1393,7 @@ dependencies = [ "block-buffer 0.12.0", "const-oid 0.10.2", "crypto-common 0.2.1", + "ctutils", ] [[package]] @@ -2188,7 +2204,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2200,6 +2216,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.2", +] + [[package]] name = "home" version = "0.5.12" @@ -2301,6 +2326,7 @@ dependencies = [ "fastcrypto", "filetime", "futures", + "hmac 0.13.0", "homedir", "ignore", "indexmap 2.14.0", @@ -2326,6 +2352,7 @@ dependencies = [ "source-map-gen", "sqlx", "strip-ansi-escapes", + "subtle", "tempfile", "tokio", "tokio-tungstenite 0.29.0", @@ -3162,7 +3189,7 @@ dependencies = [ "futures-core", "futures-util", "hkdf", - "hmac", + "hmac 0.12.1", "itertools 0.14.0", "js_option", "matrix-sdk-common", @@ -3255,7 +3282,7 @@ dependencies = [ "blake3", "chacha20poly1305", "getrandom 0.2.17", - "hmac", + "hmac 0.12.1", "pbkdf2", "rand 0.8.6", "rmp-serde", @@ -3666,7 +3693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", + "hmac 0.12.1", ] [[package]] @@ -4526,7 +4553,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -5567,7 +5594,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -5603,7 +5630,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", @@ -6474,7 +6501,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.17", "hkdf", - "hmac", + "hmac 0.12.1", "matrix-pickle", "prost", "rand 0.8.6", diff --git a/Cargo.toml b/Cargo.toml index 4bab451c..c04f5117 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_urlencoded = "0.7" sha2 = "0.11.0" +hmac = "0.13" +subtle = "2" serde_yaml = "0.9" strip-ansi-escapes = "0.2" tempfile = "3" diff --git a/server/Cargo.toml b/server/Cargo.toml index 1cde0638..c643acec 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -25,6 +25,8 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_urlencoded = { workspace = true } sha2 = { workspace = true } +hmac = { workspace = true } +subtle = { 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/matrix/config/types.rs b/server/src/chat/transport/matrix/config/types.rs index cdf896f1..d3f3d206 100644 --- a/server/src/chat/transport/matrix/config/types.rs +++ b/server/src/chat/transport/matrix/config/types.rs @@ -136,6 +136,12 @@ pub struct BotConfig { /// fail-closed). #[serde(default)] pub whatsapp_allowed_phones: Vec, + /// Meta app secret used to verify `X-Hub-Signature-256` HMAC on inbound + /// webhook requests. When set, every POST to `/webhook/whatsapp` must + /// carry a valid signature or the request is rejected. When absent or + /// empty, signature verification is skipped (backwards compatible). + #[serde(default)] + pub whatsapp_app_secret: Option, // ── Slack Bot API fields ───────────────────────────────────────── // These are only required when `transport = "slack"`. diff --git a/server/src/chat/transport/whatsapp/commands/mod.rs b/server/src/chat/transport/whatsapp/commands/mod.rs index 7f73d87d..a4b9bfd2 100644 --- a/server/src/chat/transport/whatsapp/commands/mod.rs +++ b/server/src/chat/transport/whatsapp/commands/mod.rs @@ -320,6 +320,7 @@ mod tests { history_size: 20, window_tracker: tracker, allowed_phones, + app_secret: String::new(), }) } diff --git a/server/src/chat/transport/whatsapp/mod.rs b/server/src/chat/transport/whatsapp/mod.rs index 6e6c177f..00dd7ba8 100644 --- a/server/src/chat/transport/whatsapp/mod.rs +++ b/server/src/chat/transport/whatsapp/mod.rs @@ -12,6 +12,7 @@ pub mod format; pub mod history; pub mod meta; pub mod twilio; +pub mod verify; pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history}; pub use meta::WhatsAppTransport; @@ -121,6 +122,10 @@ pub struct WhatsAppWebhookContext { /// Phone numbers allowed to send messages to the bot. /// When empty, all numbers are allowed (backwards compatible). pub allowed_phones: Vec, + /// Meta app secret for `X-Hub-Signature-256` HMAC verification. + /// When non-empty, every inbound POST is verified against this secret. + /// When empty, signature verification is skipped. + pub app_secret: String, } /// GET /webhook/whatsapp — webhook verification. @@ -158,13 +163,16 @@ pub async fn webhook_verify( /// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body. /// /// Both providers expect a `200 OK` response, even on parse errors. +/// +/// For the `"meta"` provider, the `X-Hub-Signature-256` header is verified +/// against the configured app secret (HMAC-SHA256). Requests with a missing +/// or invalid signature are rejected with `401`/`403` respectively. #[handler] pub async fn webhook_receive( req: &Request, body: poem::Body, ctx: poem::web::Data<&Arc>, ) -> Response { - let _ = req; let bytes = match body.into_bytes().await { Ok(b) => b, Err(e) => { @@ -175,6 +183,23 @@ pub async fn webhook_receive( } }; + // Verify HMAC-SHA256 signature for Meta webhooks when an app secret is configured. + if ctx.provider != "twilio" && !ctx.app_secret.is_empty() { + let signature = req.header("X-Hub-Signature-256").unwrap_or(""); + if signature.is_empty() { + slog!("[whatsapp] Missing X-Hub-Signature-256 header; rejecting request"); + return Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body("Missing signature"); + } + if !verify::verify_meta_signature(&ctx.app_secret, &bytes, signature) { + slog!("[whatsapp] X-Hub-Signature-256 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() { diff --git a/server/src/chat/transport/whatsapp/verify.rs b/server/src/chat/transport/whatsapp/verify.rs new file mode 100644 index 00000000..cd834346 --- /dev/null +++ b/server/src/chat/transport/whatsapp/verify.rs @@ -0,0 +1,102 @@ +//! HMAC-SHA256 signature verification for Meta 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`. + +use std::fmt::Write as FmtWrite; + +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; + +/// Verify the `X-Hub-Signature-256` header sent by Meta. +/// +/// Meta computes `HMAC-SHA256(app_secret, raw_body)` and sends the result +/// as `sha256=` in the `X-Hub-Signature-256` header. +/// +/// Returns `true` if the signature is valid, `false` otherwise (including +/// when the header is missing or malformed). +pub(super) fn verify_meta_signature(app_secret: &str, body: &[u8], signature: &str) -> bool { + // The header value must start with "sha256=". + let Some(hex_sig) = signature.strip_prefix("sha256=") else { + return false; + }; + + // Compute HMAC-SHA256 over the raw request body. + // `new_from_slice` accepts any key length, so `expect` is unreachable. + let mut mac = + HmacSha256::new_from_slice(app_secret.as_bytes()).expect("HMAC accepts any key size"); + mac.update(body); + let computed = mac.finalize().into_bytes(); + + // Hex-encode the computed digest (32 bytes → 64 hex chars). + let mut expected_hex = String::with_capacity(64); + for byte in computed.iter() { + write!(expected_hex, "{byte:02x}").unwrap(); + } + + // Constant-time comparison to prevent timing attacks. + expected_hex.as_bytes().ct_eq(hex_sig.as_bytes()).into() +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// Compute a reference signature using the same algorithm. + fn make_signature(secret: &str, body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize().into_bytes(); + let mut hex = String::from("sha256="); + for b in result.iter() { + write!(hex, "{b:02x}").unwrap(); + } + hex + } + + #[test] + fn valid_signature_passes() { + let secret = "my-app-secret"; + let body = b"test webhook body"; + let sig = make_signature(secret, body); + assert!(verify_meta_signature(secret, body, &sig)); + } + + #[test] + fn invalid_signature_is_rejected() { + let secret = "my-app-secret"; + let body = b"test webhook body"; + assert!(!verify_meta_signature( + secret, + body, + "sha256=0000000000000000000000000000000000000000000000000000000000000000" + )); + } + + #[test] + fn missing_header_prefix_is_rejected() { + let secret = "my-app-secret"; + let body = b"test webhook body"; + // No "sha256=" prefix. + assert!(!verify_meta_signature(secret, body, "badhash")); + } + + #[test] + fn empty_signature_is_rejected() { + assert!(!verify_meta_signature("secret", b"body", "")); + } + + #[test] + fn wrong_secret_is_rejected() { + let body = b"test webhook body"; + let sig = make_signature("correct-secret", body); + assert!(!verify_meta_signature("wrong-secret", body, &sig)); + } +} diff --git a/server/src/startup/bots.rs b/server/src/startup/bots.rs index 1391d3a8..c2ae4a68 100644 --- a/server/src/startup/bots.rs +++ b/server/src/startup/bots.rs @@ -70,6 +70,7 @@ pub(crate) fn build_bot_contexts( history_size: cfg.history_size, 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(), }) });