Skip to content

Commit 6d5e648

Browse files
jrevillardclaude
andcommitted
feat(whatsapp): add HMAC-SHA256 webhook signature verification
Implements secure webhook verification for WhatsApp Cloud API alongside the existing Slack HMAC verification: - Add verify_hmac_sha256() for X-Hub-Signature-256 header validation (WhatsApp/GitHub-style, simple body-only HMAC) - Add webhook deduplication via database to prevent replay attacks - Add mark_as_read support for blue checkmarks - Update WhatsApp capabilities.json with hmac_secret_name config - Add on_message_persisted callback for post-DB persistence actions - Move WhatsApp-specific logic to WASM channel from host The router supports both verification styles: - Slack: X-Slack-Signature + X-Slack-Request-Timestamp (v0:{ts}:{body}) - WhatsApp: X-Hub-Signature-256 (sha256={hex}) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2df9602 commit 6d5e648

29 files changed

Lines changed: 2190 additions & 398 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ bench-results/
2222
# WASM build artifacts (loaded from disk, not bundled)
2323
*.wasm
2424

25+
.serena/

FEATURE_PARITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
6565
| HTTP webhook ||| - | axum with secret validation |
6666
| REPL (simple) ||| - | For testing |
6767
| WASM channels ||| - | IronClaw innovation |
68-
| WhatsApp || | P1 | Baileys (Web), same-phone mode with echo detection |
68+
| WhatsApp || | P1 | Cloud API (WASM), DM pairing, text messages; voice/media TODO |
6969
| Telegram ||| - | WASM channel(MTProto), DM pairing, caption, /start, bot_username |
7070
| Discord ||| P2 | discord.js, thread parent binding inheritance |
7171
| Signal ||| P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
@@ -526,7 +526,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
526526
### P1 - High Priority
527527
- ❌ Slack channel (real implementation)
528528
- ✅ Telegram channel (WASM, DM pairing, caption, /start)
529-
- WhatsApp channel
529+
- WhatsApp channel (WASM, Cloud API, HMAC webhook verification, text messages)
530530
- ✅ Multi-provider failover (`FailoverProvider` with retryable error classification)
531531
- ✅ Hooks system (core lifecycle hooks + bundled/plugin/workspace hooks + outbound webhooks)
532532

channels-src/discord/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ impl Guest for DiscordChannel {
312312

313313
fn on_status(_update: StatusUpdate) {}
314314

315+
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
316+
// Discord doesn't need post-persistence actions
317+
Ok(())
318+
}
319+
315320
fn on_shutdown() {
316321
channel_host::log(
317322
channel_host::LogLevel::Info,

channels-src/slack/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,11 @@ impl Guest for SlackChannel {
306306

307307
fn on_status(_update: StatusUpdate) {}
308308

309+
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
310+
// Slack doesn't need post-persistence actions
311+
Ok(())
312+
}
313+
309314
fn on_shutdown() {
310315
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
311316
}

channels-src/telegram/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,11 @@ impl Guest for TelegramChannel {
707707
}
708708
}
709709

710+
fn on_message_persisted(_metadata_json: String) -> Result<(), String> {
711+
// Telegram doesn't need post-persistence actions like mark_as_read
712+
Ok(())
713+
}
714+
710715
fn on_shutdown() {
711716
channel_host::log(
712717
channel_host::LogLevel::Info,

channels-src/whatsapp/src/lib.rs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,98 @@ impl Guest for WhatsAppChannel {
476476

477477
fn on_status(_update: StatusUpdate) {}
478478

479+
fn on_message_persisted(metadata_json: String) -> Result<(), String> {
480+
channel_host::log(
481+
channel_host::LogLevel::Debug,
482+
"on_message_persisted callback invoked",
483+
);
484+
485+
// Parse metadata to get message_id and phone_number_id
486+
let metadata: WhatsAppMessageMetadata = match serde_json::from_str(&metadata_json) {
487+
Ok(m) => m,
488+
Err(e) => {
489+
channel_host::log(
490+
channel_host::LogLevel::Warn,
491+
&format!("Failed to parse metadata in on_message_persisted: {}", e),
492+
);
493+
// Don't fail the ACK - just log and return
494+
return Ok(());
495+
}
496+
};
497+
498+
// Read api_version from workspace (set during on_start), fallback to default
499+
let api_version = channel_host::workspace_read("channels/whatsapp/api_version")
500+
.filter(|s| !s.is_empty())
501+
.unwrap_or_else(|| "v18.0".to_string());
502+
503+
// Build WhatsApp mark_as_read API URL
504+
let url = format!(
505+
"https://graph.facebook.com/{}/{}/messages",
506+
api_version, metadata.phone_number_id
507+
);
508+
509+
// Build mark_as_read payload
510+
let payload = serde_json::json!({
511+
"messaging_product": "whatsapp",
512+
"status": "read",
513+
"message_id": metadata.message_id
514+
});
515+
516+
let payload_bytes = serde_json::to_vec(&payload)
517+
.map_err(|e| format!("Failed to serialize mark_as_read payload: {}", e))?;
518+
519+
// Headers with Bearer token placeholder
520+
// Host will inject the actual access token
521+
let headers = serde_json::json!({
522+
"Content-Type": "application/json",
523+
"Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}"
524+
});
525+
526+
channel_host::log(
527+
channel_host::LogLevel::Debug,
528+
&format!("Calling mark_as_read for message: {}", metadata.message_id),
529+
);
530+
531+
let result = channel_host::http_request(
532+
"POST",
533+
&url,
534+
&headers.to_string(),
535+
Some(&payload_bytes),
536+
None,
537+
);
538+
539+
match result {
540+
Ok(http_response) => {
541+
if http_response.status >= 200 && http_response.status < 300 {
542+
channel_host::log(
543+
channel_host::LogLevel::Debug,
544+
&format!("Marked message {} as read", metadata.message_id),
545+
);
546+
Ok(())
547+
} else {
548+
let body_str = String::from_utf8_lossy(&http_response.body);
549+
channel_host::log(
550+
channel_host::LogLevel::Warn,
551+
&format!(
552+
"mark_as_read API error: {} - {}",
553+
http_response.status, body_str
554+
),
555+
);
556+
// Don't fail the ACK - mark_as_read is best-effort
557+
Ok(())
558+
}
559+
}
560+
Err(e) => {
561+
channel_host::log(
562+
channel_host::LogLevel::Warn,
563+
&format!("mark_as_read HTTP request failed: {}", e),
564+
);
565+
// Don't fail the ACK - mark_as_read is best-effort
566+
Ok(())
567+
}
568+
}
569+
}
570+
479571
fn on_shutdown() {
480572
channel_host::log(
481573
channel_host::LogLevel::Info,
@@ -652,6 +744,11 @@ fn handle_message(
652744
return;
653745
}
654746

747+
// Note: mark_as_read is now handled by the host after DB persistence.
748+
// The host will call the WhatsApp API directly after receiving the ACK
749+
// from the agent loop. This ensures the webhook returns 200 OK only after
750+
// the message is durably stored.
751+
655752
// Build metadata for response routing
656753
// This is critical - the response handler uses this to know where to send
657754
let metadata = WhatsAppMessageMetadata {
@@ -681,10 +778,6 @@ fn handle_message(
681778
);
682779
}
683780

684-
// ============================================================================
685-
// Utilities
686-
// ============================================================================
687-
688781
// ============================================================================
689782
// Permission & Pairing
690783
// ============================================================================

channels-src/whatsapp/whatsapp.capabilities.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@
1616
"prompt": "Webhook verify token (leave empty to auto-generate)",
1717
"optional": true,
1818
"auto_generate": { "length": 32 }
19+
},
20+
{
21+
"name": "whatsapp_app_secret",
22+
"prompt": "Enter your WhatsApp App Secret (from Meta Developer Portal > App Settings > Basic)",
23+
"validation": "^[a-f0-9]{32}$"
1924
}
2025
],
21-
"validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}",
26+
"validation_endpoint": "https://graph.facebook.com/v25.0/me?access_token={whatsapp_access_token}",
2227
"setup_url": "https://developers.facebook.com/apps"
2328
},
2429
"capabilities": {
@@ -45,12 +50,14 @@
4550
"webhook": {
4651
"secret_header": "X-Hub-Signature-256",
4752
"secret_name": "whatsapp_verify_token",
48-
"verify_token_param": "hub.verify_token"
53+
"verification_mode": "query_param",
54+
"hmac_secret_name": "whatsapp_app_secret",
55+
"message_id_json_pointer": "/message_id"
4956
}
5057
}
5158
},
5259
"config": {
53-
"api_version": "v18.0",
60+
"api_version": "v25.0",
5461
"reply_to_message": true,
5562
"owner_id": null,
5663
"dm_policy": "pairing",

migrations/V10__webhook_dedup.sql

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Webhook message deduplication table
2+
-- Tracks which webhook messages have been processed to prevent duplicates
3+
-- when WhatsApp retries after a 500 response
4+
5+
CREATE TABLE webhook_message_dedup (
6+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7+
channel TEXT NOT NULL,
8+
external_message_id TEXT NOT NULL,
9+
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10+
UNIQUE(channel, external_message_id)
11+
);
12+
13+
-- Index for fast lookup by channel + message_id
14+
CREATE INDEX idx_webhook_dedup_channel_msg ON webhook_message_dedup(channel, external_message_id);
15+
16+
-- Auto-cleanup: delete entries older than 7 days (WhatsApp max retry window)
17+
-- Meta's webhook retry policy: retries for up to 7 days on 5xx errors
18+
-- See: https://developers.facebook.com/docs/graph-api/webhooks#retry
19+
-- This keeps the table small while covering all retry scenarios
20+
CREATE OR REPLACE FUNCTION cleanup_old_dedup_entries() RETURNS void AS $$
21+
BEGIN
22+
DELETE FROM webhook_message_dedup WHERE processed_at < NOW() - INTERVAL '7 days';
23+
END;
24+
$$ LANGUAGE plpgsql;

src/agent/agent_loop.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ pub struct AgentDeps {
7777
pub sse_tx: Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>,
7878
/// HTTP interceptor for trace recording/replay.
7979
pub http_interceptor: Option<Arc<dyn crate::llm::recording::HttpInterceptor>>,
80+
/// WASM channel router for webhook ACK signaling.
81+
/// When set, the agent will signal ACK after persisting messages,
82+
/// allowing webhooks to wait for persistence before returning 200 OK.
83+
pub wasm_router: Option<Arc<crate::channels::wasm::router::WasmChannelRouter>>,
8084
}
8185

8286
/// The main agent that coordinates all components.

src/agent/dispatcher.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,7 @@ mod tests {
10781078
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
10791079
sse_tx: None,
10801080
http_interceptor: None,
1081+
wasm_router: None,
10811082
};
10821083

10831084
Agent::new(
@@ -1818,6 +1819,7 @@ mod tests {
18181819
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
18191820
sse_tx: None,
18201821
http_interceptor: None,
1822+
wasm_router: None,
18211823
};
18221824

18231825
Agent::new(
@@ -1931,6 +1933,7 @@ mod tests {
19311933
cost_guard: Arc::new(CostGuard::new(CostGuardConfig::default())),
19321934
sse_tx: None,
19331935
http_interceptor: None,
1936+
wasm_router: None,
19341937
};
19351938

19361939
Agent::new(

0 commit comments

Comments
 (0)