huskies: merge 807

This commit is contained in:
dave
2026-04-28 20:44:31 +00:00
parent 2a77f73ba4
commit 97b9eaa39d
8 changed files with 175 additions and 9 deletions
Generated
+35 -8
View File
@@ -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",
+2
View File
@@ -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"
+2
View File
@@ -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"] }
@@ -136,6 +136,12 @@ pub struct BotConfig {
/// fail-closed).
#[serde(default)]
pub whatsapp_allowed_phones: Vec<String>,
/// 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<String>,
// ── Slack Bot API fields ─────────────────────────────────────────
// These are only required when `transport = "slack"`.
@@ -320,6 +320,7 @@ mod tests {
history_size: 20,
window_tracker: tracker,
allowed_phones,
app_secret: String::new(),
})
}
+26 -1
View File
@@ -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<String>,
/// 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<WhatsAppWebhookContext>>,
) -> 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() {
@@ -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=<hex_digest>`. 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<Sha256>;
/// Verify the `X-Hub-Signature-256` header sent by Meta.
///
/// Meta computes `HMAC-SHA256(app_secret, raw_body)` and sends the result
/// as `sha256=<hex_digest>` 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));
}
}
+1
View File
@@ -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(),
})
});