huskies: merge 808

This commit is contained in:
dave
2026-04-28 23:12:31 +00:00
parent 6092f7efbb
commit ab01a62bd1
7 changed files with 214 additions and 5 deletions
Generated
+2
View File
@@ -2317,6 +2317,7 @@ version = "0.10.4"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
"base64",
"bft-json-crdt", "bft-json-crdt",
"bytes", "bytes",
"chrono", "chrono",
@@ -2348,6 +2349,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"serde_yaml", "serde_yaml",
"sha1",
"sha2 0.11.0", "sha2 0.11.0",
"source-map-gen", "source-map-gen",
"sqlx", "sqlx",
+2
View File
@@ -22,9 +22,11 @@ rust-embed = "8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_urlencoded = "0.7" serde_urlencoded = "0.7"
sha1 = "0.10"
sha2 = "0.11.0" sha2 = "0.11.0"
hmac = "0.13" hmac = "0.13"
subtle = "2" subtle = "2"
base64 = "0.22"
serde_yaml = "0.9" serde_yaml = "0.9"
strip-ansi-escapes = "0.2" strip-ansi-escapes = "0.2"
tempfile = "3" tempfile = "3"
+2
View File
@@ -24,9 +24,11 @@ rust-embed = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_urlencoded = { workspace = true } serde_urlencoded = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
hmac = { workspace = true } hmac = { workspace = true }
subtle = { workspace = true } subtle = { workspace = true }
base64 = { 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"] }
@@ -321,6 +321,7 @@ mod tests {
window_tracker: tracker, window_tracker: tracker,
allowed_phones, allowed_phones,
app_secret: String::new(), app_secret: String::new(),
twilio_auth_token: String::new(),
}) })
} }
+50
View File
@@ -7,11 +7,17 @@
//! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp //! - [`webhook_verify`] / [`webhook_receive`] — Poem handlers for the WhatsApp
//! webhook (GET verification handshake + POST incoming messages). //! webhook (GET verification handshake + POST incoming messages).
/// Incoming message command dispatch (e.g. `!status`, `!help`).
pub mod commands; pub mod commands;
/// WhatsApp message formatting helpers.
pub mod format; pub mod format;
/// Conversation history and 24-hour messaging-window tracking.
pub mod history; pub mod history;
/// Meta Graph API transport implementation.
pub mod meta; pub mod meta;
/// Twilio REST API transport implementation.
pub mod twilio; pub mod twilio;
/// HMAC signature verification for Meta and Twilio webhooks.
pub mod verify; pub mod verify;
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history}; pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
@@ -35,17 +41,20 @@ pub struct WebhookPayload {
pub entry: Vec<WebhookEntry>, pub entry: Vec<WebhookEntry>,
} }
/// One entry in the top-level `entry` array of a Meta webhook payload.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookEntry { pub struct WebhookEntry {
#[serde(default)] #[serde(default)]
pub changes: Vec<WebhookChange>, pub changes: Vec<WebhookChange>,
} }
/// One change event within a [`WebhookEntry`].
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookChange { pub struct WebhookChange {
pub value: Option<WebhookValue>, pub value: Option<WebhookValue>,
} }
/// The `value` object inside a [`WebhookChange`], containing messages and metadata.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookValue { pub struct WebhookValue {
#[serde(default)] #[serde(default)]
@@ -54,12 +63,14 @@ pub struct WebhookValue {
pub metadata: Option<WebhookMetadata>, pub metadata: Option<WebhookMetadata>,
} }
/// Phone-number metadata attached to a [`WebhookValue`].
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookMetadata { pub struct WebhookMetadata {
#[allow(dead_code)] #[allow(dead_code)]
pub phone_number_id: Option<String>, pub phone_number_id: Option<String>,
} }
/// A single inbound WhatsApp message from the Meta webhook.
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookMessage { pub struct WebhookMessage {
pub from: Option<String>, pub from: Option<String>,
@@ -67,6 +78,7 @@ pub struct WebhookMessage {
pub text: Option<WebhookText>, pub text: Option<WebhookText>,
} }
/// The `text` body of a [`WebhookMessage`].
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
pub struct WebhookText { pub struct WebhookText {
pub body: Option<String>, pub body: Option<String>,
@@ -126,6 +138,10 @@ pub struct WhatsAppWebhookContext {
/// When non-empty, every inbound POST is verified against this secret. /// When non-empty, every inbound POST is verified against this secret.
/// When empty, signature verification is skipped. /// When empty, signature verification is skipped.
pub app_secret: String, 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. /// 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 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() {
@@ -238,6 +272,22 @@ pub async fn webhook_receive(
Response::builder().status(StatusCode::OK).body("ok") 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 ─────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
+156 -5
View File
@@ -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 //! - **Meta** signs every inbound request with the app secret and sends the
//! the result as `X-Hub-Signature-256: sha256=<hex_digest>`. This module //! result as `X-Hub-Signature-256: sha256=<hex_digest>` (HMAC-SHA256).
//! verifies that signature using the `hmac` + `sha2` crates and a //! - **Twilio** signs every inbound request with the auth token and sends the
//! constant-time comparison from `subtle`. //! result as `X-Twilio-Signature: <base64_digest>` (HMAC-SHA1).
//!
//! Both paths use a constant-time comparison from `subtle` to prevent timing
//! attacks.
use std::fmt::Write as FmtWrite; use std::fmt::Write as FmtWrite;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use hmac::{Hmac, KeyInit, Mac}; use hmac::{Hmac, KeyInit, Mac};
use sha1::Digest as Sha1Digest;
use sha2::Sha256; use sha2::Sha256;
use subtle::ConstantTimeEq; 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() 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 &params {
s.push_str(k);
s.push_str(v);
}
s
}
// ── Tests ─────────────────────────────────────────────────────────────── // ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]
@@ -99,4 +192,62 @@ mod tests {
let sig = make_signature("correct-secret", body); let sig = make_signature("correct-secret", body);
assert!(!verify_meta_signature("wrong-secret", body, &sig)); 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));
}
} }
+1
View File
@@ -71,6 +71,7 @@ pub(crate) fn build_bot_contexts(
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(), app_secret: cfg.whatsapp_app_secret.clone().unwrap_or_default(),
twilio_auth_token: cfg.twilio_auth_token.clone().unwrap_or_default(),
}) })
}); });