huskies: merge 807
This commit is contained in:
Generated
+35
-8
@@ -782,6 +782,12 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmov"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colored"
|
name = "colored"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
@@ -1073,6 +1079,15 @@ dependencies = [
|
|||||||
"cipher",
|
"cipher",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctutils"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||||
|
dependencies = [
|
||||||
|
"cmov",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "curve25519-dalek"
|
name = "curve25519-dalek"
|
||||||
version = "4.1.3"
|
version = "4.1.3"
|
||||||
@@ -1378,6 +1393,7 @@ dependencies = [
|
|||||||
"block-buffer 0.12.0",
|
"block-buffer 0.12.0",
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
"crypto-common 0.2.1",
|
"crypto-common 0.2.1",
|
||||||
|
"ctutils",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2188,7 +2204,7 @@ version = "0.12.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2200,6 +2216,15 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"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]]
|
[[package]]
|
||||||
name = "home"
|
name = "home"
|
||||||
version = "0.5.12"
|
version = "0.5.12"
|
||||||
@@ -2301,6 +2326,7 @@ dependencies = [
|
|||||||
"fastcrypto",
|
"fastcrypto",
|
||||||
"filetime",
|
"filetime",
|
||||||
"futures",
|
"futures",
|
||||||
|
"hmac 0.13.0",
|
||||||
"homedir",
|
"homedir",
|
||||||
"ignore",
|
"ignore",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
@@ -2326,6 +2352,7 @@ dependencies = [
|
|||||||
"source-map-gen",
|
"source-map-gen",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strip-ansi-escapes",
|
"strip-ansi-escapes",
|
||||||
|
"subtle",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
@@ -3162,7 +3189,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"js_option",
|
"js_option",
|
||||||
"matrix-sdk-common",
|
"matrix-sdk-common",
|
||||||
@@ -3255,7 +3282,7 @@ dependencies = [
|
|||||||
"blake3",
|
"blake3",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
@@ -3666,7 +3693,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4526,7 +4553,7 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5567,7 +5594,7 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"itoa",
|
"itoa",
|
||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
@@ -5603,7 +5630,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"home",
|
"home",
|
||||||
"itoa",
|
"itoa",
|
||||||
"log",
|
"log",
|
||||||
@@ -6474,7 +6501,7 @@ dependencies = [
|
|||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac 0.12.1",
|
||||||
"matrix-pickle",
|
"matrix-pickle",
|
||||||
"prost",
|
"prost",
|
||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_urlencoded = "0.7"
|
serde_urlencoded = "0.7"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
|
hmac = "0.13"
|
||||||
|
subtle = "2"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
strip-ansi-escapes = "0.2"
|
strip-ansi-escapes = "0.2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ serde = { workspace = true, features = ["derive"] }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_urlencoded = { workspace = true }
|
serde_urlencoded = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
hmac = { workspace = true }
|
||||||
|
subtle = { workspace = true }
|
||||||
serde_yaml = { workspace = true }
|
serde_yaml = { workspace = true }
|
||||||
strip-ansi-escapes = { workspace = true }
|
strip-ansi-escapes = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "process"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "process"] }
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ pub struct BotConfig {
|
|||||||
/// fail-closed).
|
/// fail-closed).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub whatsapp_allowed_phones: Vec<String>,
|
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 ─────────────────────────────────────────
|
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||||
// These are only required when `transport = "slack"`.
|
// These are only required when `transport = "slack"`.
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ mod tests {
|
|||||||
history_size: 20,
|
history_size: 20,
|
||||||
window_tracker: tracker,
|
window_tracker: tracker,
|
||||||
allowed_phones,
|
allowed_phones,
|
||||||
|
app_secret: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod format;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod twilio;
|
pub mod twilio;
|
||||||
|
pub mod verify;
|
||||||
|
|
||||||
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
|
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
|
||||||
pub use meta::WhatsAppTransport;
|
pub use meta::WhatsAppTransport;
|
||||||
@@ -121,6 +122,10 @@ pub struct WhatsAppWebhookContext {
|
|||||||
/// Phone numbers allowed to send messages to the bot.
|
/// Phone numbers allowed to send messages to the bot.
|
||||||
/// When empty, all numbers are allowed (backwards compatible).
|
/// When empty, all numbers are allowed (backwards compatible).
|
||||||
pub allowed_phones: Vec<String>,
|
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.
|
/// GET /webhook/whatsapp — webhook verification.
|
||||||
@@ -158,13 +163,16 @@ pub async fn webhook_verify(
|
|||||||
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
|
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
|
||||||
///
|
///
|
||||||
/// Both providers expect a `200 OK` response, even on parse errors.
|
/// 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]
|
#[handler]
|
||||||
pub async fn webhook_receive(
|
pub async fn webhook_receive(
|
||||||
req: &Request,
|
req: &Request,
|
||||||
body: poem::Body,
|
body: poem::Body,
|
||||||
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let _ = req;
|
|
||||||
let bytes = match body.into_bytes().await {
|
let bytes = match body.into_bytes().await {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
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 messages = if ctx.provider == "twilio" {
|
||||||
let msgs = extract_twilio_text_messages(&bytes);
|
let msgs = extract_twilio_text_messages(&bytes);
|
||||||
if msgs.is_empty() {
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ pub(crate) fn build_bot_contexts(
|
|||||||
history_size: cfg.history_size,
|
history_size: cfg.history_size,
|
||||||
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
||||||
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
|
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
|
||||||
|
app_secret: cfg.whatsapp_app_secret.clone().unwrap_or_default(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user