huskies: merge 808
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -321,6 +321,7 @@ mod tests {
|
||||
window_tracker: tracker,
|
||||
allowed_phones,
|
||||
app_secret: String::new(),
|
||||
twilio_auth_token: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<WebhookEntry>,
|
||||
}
|
||||
|
||||
/// One entry in the top-level `entry` array of a Meta webhook payload.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct WebhookEntry {
|
||||
#[serde(default)]
|
||||
pub changes: Vec<WebhookChange>,
|
||||
}
|
||||
|
||||
/// One change event within a [`WebhookEntry`].
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct WebhookChange {
|
||||
pub value: Option<WebhookValue>,
|
||||
}
|
||||
|
||||
/// 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<WebhookMetadata>,
|
||||
}
|
||||
|
||||
/// Phone-number metadata attached to a [`WebhookValue`].
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct WebhookMetadata {
|
||||
#[allow(dead_code)]
|
||||
pub phone_number_id: Option<String>,
|
||||
}
|
||||
|
||||
/// A single inbound WhatsApp message from the Meta webhook.
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct WebhookMessage {
|
||||
pub from: Option<String>,
|
||||
@@ -67,6 +78,7 @@ pub struct WebhookMessage {
|
||||
pub text: Option<WebhookText>,
|
||||
}
|
||||
|
||||
/// The `text` body of a [`WebhookMessage`].
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct WebhookText {
|
||||
pub body: Option<String>,
|
||||
@@ -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)]
|
||||
|
||||
@@ -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=<hex_digest>`. 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=<hex_digest>` (HMAC-SHA256).
|
||||
//! - **Twilio** signs every inbound request with the auth token and sends the
|
||||
//! 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 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user