use std::collections::HashMap;
use std::sync::Arc;
use axum::{
Json, Router,
body::Bytes,
extract::{Path, Query, State},
http::{HeaderMap, Method, StatusCode},
response::IntoResponse,
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use crate::channels::wasm::wrapper::WasmChannel;
#[derive(Debug, Clone)]
pub struct RegisteredEndpoint {
pub channel_name: String,
pub path: String,
pub methods: Vec<String>,
pub require_secret: bool,
}
pub struct WasmChannelRouter {
channels: RwLock<HashMap<String, Arc<WasmChannel>>>,
path_to_channel: RwLock<HashMap<String, String>>,
secrets: RwLock<HashMap<String, String>>,
secret_headers: RwLock<HashMap<String, String>>,
signature_keys: RwLock<HashMap<String, String>>,
hmac_secrets: RwLock<HashMap<String, String>>,
}
impl WasmChannelRouter {
pub fn new() -> Self {
Self {
channels: RwLock::new(HashMap::new()),
path_to_channel: RwLock::new(HashMap::new()),
secrets: RwLock::new(HashMap::new()),
secret_headers: RwLock::new(HashMap::new()),
signature_keys: RwLock::new(HashMap::new()),
hmac_secrets: RwLock::new(HashMap::new()),
}
}
pub async fn register(
&self,
channel: Arc<WasmChannel>,
endpoints: Vec<RegisteredEndpoint>,
secret: Option<String>,
secret_header: Option<String>,
) {
let name = channel.channel_name().to_string();
self.channels.write().await.insert(name.clone(), channel);
let mut path_map = self.path_to_channel.write().await;
for endpoint in endpoints {
path_map.insert(endpoint.path.clone(), name.clone());
tracing::info!(
channel = %name,
path = %endpoint.path,
methods = ?endpoint.methods,
"Registered WASM channel HTTP endpoint"
);
}
if let Some(s) = secret {
self.secrets.write().await.insert(name.clone(), s);
}
if let Some(h) = secret_header {
self.secret_headers.write().await.insert(name, h);
}
}
pub async fn get_secret_header(&self, channel_name: &str) -> String {
self.secret_headers
.read()
.await
.get(channel_name)
.cloned()
.unwrap_or_else(|| "X-Webhook-Secret".to_string())
}
pub async fn update_secret(&self, channel_name: &str, secret: String) {
self.secrets
.write()
.await
.insert(channel_name.to_string(), secret);
tracing::info!(
channel = %channel_name,
"Updated webhook secret for channel"
);
}
pub async fn unregister(&self, channel_name: &str) {
self.channels.write().await.remove(channel_name);
self.secrets.write().await.remove(channel_name);
self.secret_headers.write().await.remove(channel_name);
self.signature_keys.write().await.remove(channel_name);
self.hmac_secrets.write().await.remove(channel_name);
self.path_to_channel
.write()
.await
.retain(|_, name| name != channel_name);
tracing::info!(
channel = %channel_name,
"Unregistered WASM channel"
);
}
pub async fn get_channel_for_path(&self, path: &str) -> Option<Arc<WasmChannel>> {
let path_map = self.path_to_channel.read().await;
let channel_name = path_map.get(path)?;
self.channels.read().await.get(channel_name).cloned()
}
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
let secrets = self.secrets.read().await;
match secrets.get(channel_name) {
Some(expected) => expected == provided,
None => true, }
}
pub async fn requires_secret(&self, channel_name: &str) -> bool {
self.secrets.read().await.contains_key(channel_name)
}
pub async fn list_channels(&self) -> Vec<String> {
self.channels.read().await.keys().cloned().collect()
}
pub async fn list_paths(&self) -> Vec<String> {
self.path_to_channel.read().await.keys().cloned().collect()
}
pub async fn register_signature_key(
&self,
channel_name: &str,
public_key_hex: &str,
) -> Result<(), String> {
use ed25519_dalek::VerifyingKey;
let key_bytes = hex::decode(public_key_hex).map_err(|e| format!("invalid hex: {e}"))?;
VerifyingKey::try_from(key_bytes.as_slice())
.map_err(|e| format!("invalid Ed25519 public key: {e}"))?;
self.signature_keys
.write()
.await
.insert(channel_name.to_string(), public_key_hex.to_string());
Ok(())
}
pub async fn get_signature_key(&self, channel_name: &str) -> Option<String> {
self.signature_keys.read().await.get(channel_name).cloned()
}
pub async fn register_hmac_secret(&self, channel_name: &str, secret: &str) {
self.hmac_secrets
.write()
.await
.insert(channel_name.to_string(), secret.to_string());
}
pub async fn get_hmac_secret(&self, channel_name: &str) -> Option<String> {
self.hmac_secrets.read().await.get(channel_name).cloned()
}
}
impl Default for WasmChannelRouter {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)]
#[derive(Clone)]
pub struct RouterState {
router: Arc<WasmChannelRouter>,
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
}
impl RouterState {
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
Self {
router,
extension_manager: None,
}
}
pub fn with_extension_manager(
mut self,
manager: Arc<crate::extensions::ExtensionManager>,
) -> Self {
self.extension_manager = Some(manager);
self
}
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct WasmWebhookRequest {
#[serde(default)]
pub secret: Option<String>,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
struct HealthResponse {
status: String,
channels: Vec<String>,
}
#[allow(dead_code)]
async fn health_handler(State(state): State<RouterState>) -> impl IntoResponse {
let channels = state.router.list_channels().await;
Json(HealthResponse {
status: "healthy".to_string(),
channels,
})
}
async fn webhook_handler(
State(state): State<RouterState>,
method: Method,
Path(path): Path<String>,
Query(query): Query<HashMap<String, String>>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
let full_path = format!("/webhook/{}", path);
tracing::info!(
method = %method,
path = %full_path,
body_len = body.len(),
"Webhook request received"
);
let channel = match state.router.get_channel_for_path(&full_path).await {
Some(c) => c,
None => {
tracing::warn!(
path = %full_path,
"No channel registered for webhook path"
);
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Channel not found for path",
"path": full_path
})),
);
}
};
tracing::info!(
channel = %channel.channel_name(),
"Found channel for webhook"
);
let channel_name = channel.channel_name();
let mut did_authenticate = false;
if state.router.requires_secret(channel_name).await {
let secret_header_name = state.router.get_secret_header(channel_name).await;
let provided_secret = query
.get("secret")
.cloned()
.or_else(|| {
headers
.get(&secret_header_name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
.or_else(|| {
if secret_header_name != "X-Webhook-Secret" {
headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
} else {
None
}
});
tracing::debug!(
channel = %channel_name,
has_provided_secret = provided_secret.is_some(),
provided_secret_len = provided_secret.as_ref().map(|s| s.len()),
"Checking webhook secret"
);
match provided_secret {
Some(secret) => {
if !state.router.validate_secret(channel_name, &secret).await {
tracing::warn!(
channel = %channel_name,
"Webhook secret validation failed"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid webhook secret"
})),
);
}
tracing::debug!(channel = %channel_name, "Webhook secret validated");
did_authenticate = true;
}
None => {
tracing::warn!(
channel = %channel_name,
"Webhook secret required but not provided"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Webhook secret required"
})),
);
}
}
}
if let Some(pub_key_hex) = state.router.get_signature_key(channel_name).await {
let sig_hex = headers
.get("x-signature-ed25519")
.and_then(|v| v.to_str().ok());
let timestamp = headers
.get("x-signature-timestamp")
.and_then(|v| v.to_str().ok());
match (sig_hex, timestamp) {
(Some(sig), Some(ts)) => {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_discord_signature(
&pub_key_hex,
sig,
ts,
&body,
now_secs,
) {
tracing::warn!(
channel = %channel_name,
"Ed25519 signature verification failed"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid signature"
})),
);
}
tracing::debug!(channel = %channel_name, "Ed25519 signature verified");
did_authenticate = true;
}
_ => {
tracing::warn!(
channel = %channel_name,
"Signature headers missing but key is registered"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Missing signature headers"
})),
);
}
}
}
if let Some(hmac_secret) = state.router.get_hmac_secret(channel_name).await {
let timestamp = headers
.get("x-slack-request-timestamp")
.and_then(|v| v.to_str().ok());
let sig_header = headers
.get("x-slack-signature")
.and_then(|v| v.to_str().ok());
match (timestamp, sig_header) {
(Some(ts), Some(sig)) => {
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
if !crate::channels::wasm::signature::verify_slack_signature(
&hmac_secret,
ts,
&body,
sig,
now_secs,
) {
tracing::warn!(
channel = %channel_name,
"HMAC-SHA256 signature verification failed"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid Slack signature"
})),
);
}
tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified");
did_authenticate = true;
}
_ => {
tracing::warn!(
channel = %channel_name,
"Slack signature headers missing but secret is registered"
);
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Missing Slack signature headers"
})),
);
}
}
}
let headers_map: HashMap<String, String> = headers
.iter()
.filter_map(|(k, v)| {
v.to_str()
.ok()
.map(|v| (k.as_str().to_string(), v.to_string()))
})
.collect();
let secret_validated = did_authenticate;
tracing::info!(
channel = %channel_name,
secret_validated = secret_validated,
"Calling WASM channel on_http_request"
);
match channel
.call_on_http_request(
method.as_str(),
&full_path,
&headers_map,
&query,
&body,
secret_validated,
)
.await
{
Ok(response) => {
let status =
StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
tracing::info!(
channel = %channel_name,
status = %status,
body_len = response.body.len(),
"WASM channel on_http_request completed successfully"
);
let body_json: serde_json::Value = serde_json::from_slice(&response.body)
.unwrap_or_else(|_| {
serde_json::json!({
"raw": String::from_utf8_lossy(&response.body).to_string()
})
});
(status, Json(body_json))
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"WASM channel callback failed"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Channel callback failed",
"details": e.to_string()
})),
)
}
}
}
#[allow(dead_code)]
async fn oauth_callback_handler(
State(_state): State<RouterState>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let code = params.get("code").cloned().unwrap_or_default();
let _state = params.get("state").cloned().unwrap_or_default();
if code.is_empty() {
let error = params
.get("error")
.cloned()
.unwrap_or_else(|| "unknown".to_string());
return (
StatusCode::BAD_REQUEST,
axum::response::Html(format!(
"<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
display: flex; justify-content: center; align-items: center; \
height: 100vh; margin: 0; background: #191919; color: white;\">\
<div style=\"text-align: center;\">\
<h1>Authorization Failed</h1>\
<p>Error: {}</p>\
</div></body></html>",
error
)),
);
}
(
StatusCode::OK,
axum::response::Html(
"<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
display: flex; justify-content: center; align-items: center; \
height: 100vh; margin: 0; background: #191919; color: white;\">\
<div style=\"text-align: center;\">\
<h1>Connected!</h1>\
<p>You can close this window and return to IronClaw.</p>\
</div></body></html>"
.to_string(),
),
)
}
pub fn create_wasm_channel_router(
router: Arc<WasmChannelRouter>,
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
) -> Router {
let mut state = RouterState::new(router);
if let Some(manager) = extension_manager {
state = state.with_extension_manager(manager);
}
Router::new()
.route("/wasm-channels/health", get(health_handler))
.route("/oauth/callback", get(oauth_callback_handler))
.route("/webhook/{*path}", get(webhook_handler))
.route("/webhook/{*path}", post(webhook_handler))
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::channels::wasm::capabilities::ChannelCapabilities;
use crate::channels::wasm::router::{RegisteredEndpoint, WasmChannelRouter};
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::WasmChannel;
use crate::pairing::PairingStore;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let prepared = Arc::new(PreparedChannelModule {
name: name.to_string(),
description: format!("Test channel: {}", name),
component: None,
limits: ResourceLimits::default(),
});
let capabilities =
ChannelCapabilities::for_channel(name).with_path(format!("/webhook/{}", name));
Arc::new(WasmChannel::new(
runtime,
prepared,
capabilities,
"default",
"{}".to_string(),
Arc::new(PairingStore::new()),
None,
))
}
#[tokio::test]
async fn test_router_register_and_lookup() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
let endpoints = vec![RegisteredEndpoint {
channel_name: "slack".to_string(),
path: "/webhook/slack".to_string(),
methods: vec!["POST".to_string()],
require_secret: true,
}];
router
.register(channel, endpoints, Some("secret123".to_string()), None)
.await;
let found = router.get_channel_for_path("/webhook/slack").await;
assert!(found.is_some());
assert_eq!(found.unwrap().channel_name(), "slack");
let not_found = router.get_channel_for_path("/webhook/telegram").await;
assert!(not_found.is_none());
}
#[tokio::test]
async fn test_router_secret_validation() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
router
.register(channel, vec![], Some("secret123".to_string()), None)
.await;
assert!(router.validate_secret("slack", "secret123").await);
assert!(!router.validate_secret("slack", "wrong").await);
let channel2 = create_test_channel("telegram");
router.register(channel2, vec![], None, None).await;
assert!(router.validate_secret("telegram", "anything").await);
}
#[tokio::test]
async fn test_router_unregister() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
let endpoints = vec![RegisteredEndpoint {
channel_name: "slack".to_string(),
path: "/webhook/slack".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
router.register(channel, endpoints, None, None).await;
assert!(
router
.get_channel_for_path("/webhook/slack")
.await
.is_some()
);
router.unregister("slack").await;
assert!(
router
.get_channel_for_path("/webhook/slack")
.await
.is_none()
);
}
#[tokio::test]
async fn test_router_list_channels() {
let router = WasmChannelRouter::new();
let channel1 = create_test_channel("slack");
let channel2 = create_test_channel("telegram");
router.register(channel1, vec![], None, None).await;
router.register(channel2, vec![], None, None).await;
let channels = router.list_channels().await;
assert_eq!(channels.len(), 2);
assert!(channels.contains(&"slack".to_string()));
assert!(channels.contains(&"telegram".to_string()));
}
#[tokio::test]
async fn test_router_secret_header() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("telegram");
router
.register(
channel,
vec![],
Some("secret123".to_string()),
Some("X-Telegram-Bot-Api-Secret-Token".to_string()),
)
.await;
assert_eq!(
router.get_secret_header("telegram").await,
"X-Telegram-Bot-Api-Secret-Token"
);
let channel2 = create_test_channel("slack");
router
.register(channel2, vec![], Some("secret456".to_string()), None)
.await;
assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret");
}
#[tokio::test]
async fn test_register_and_get_hmac_secret() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
router.register(channel, vec![], None, None).await;
let hmac_secret = "my-slack-signing-secret";
router.register_hmac_secret("slack", hmac_secret).await;
let retrieved = router.get_hmac_secret("slack").await;
assert_eq!(retrieved, Some(hmac_secret.to_string()));
}
#[tokio::test]
async fn test_no_hmac_secret_returns_none() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
router.register(channel, vec![], None, None).await;
let secret = router.get_hmac_secret("slack").await;
assert!(secret.is_none());
}
#[tokio::test]
async fn test_unregister_removes_hmac_secret() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
let endpoints = vec![RegisteredEndpoint {
channel_name: "slack".to_string(),
path: "/webhook/slack".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
router.register(channel, endpoints, None, None).await;
router.register_hmac_secret("slack", "signing-secret").await;
assert!(router.get_hmac_secret("slack").await.is_some());
router.unregister("slack").await;
assert!(router.get_hmac_secret("slack").await.is_none());
}
#[tokio::test]
async fn test_register_and_get_signature_key() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
router
.register_signature_key("discord", fake_pub_key)
.await
.unwrap();
let key = router.get_signature_key("discord").await;
assert_eq!(key, Some(fake_pub_key.to_string()));
}
#[tokio::test]
async fn test_no_signature_key_returns_none() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("slack");
router.register(channel, vec![], None, None).await;
let key = router.get_signature_key("slack").await;
assert!(key.is_none());
}
#[tokio::test]
async fn test_unregister_removes_signature_key() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
let endpoints = vec![RegisteredEndpoint {
channel_name: "discord".to_string(),
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
router.register(channel, endpoints, None, None).await;
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
router
.register_signature_key("discord", valid_key)
.await
.unwrap();
assert!(router.get_signature_key("discord").await.is_some());
router.unregister("discord").await;
assert!(router.get_signature_key("discord").await.is_none());
}
#[tokio::test]
async fn test_register_valid_signature_key_succeeds() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
let result = router.register_signature_key("discord", valid_key).await;
assert!(result.is_ok(), "Valid Ed25519 key should be accepted");
}
#[tokio::test]
async fn test_register_invalid_hex_key_fails() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let result = router
.register_signature_key("discord", "not-valid-hex-zzz")
.await;
assert!(result.is_err(), "Invalid hex should be rejected");
}
#[tokio::test]
async fn test_register_wrong_length_key_fails() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let short_key = hex::encode([0u8; 16]);
let result = router.register_signature_key("discord", &short_key).await;
assert!(result.is_err(), "Wrong-length key should be rejected");
}
#[tokio::test]
async fn test_register_empty_key_fails() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let result = router.register_signature_key("discord", "").await;
assert!(result.is_err(), "Empty key should be rejected");
}
#[tokio::test]
async fn test_valid_key_is_retrievable() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
router
.register_signature_key("discord", valid_key)
.await
.unwrap();
let stored = router.get_signature_key("discord").await;
assert_eq!(stored, Some(valid_key.to_string()));
}
#[tokio::test]
async fn test_invalid_key_does_not_store() {
let router = WasmChannelRouter::new();
let channel = create_test_channel("discord");
router.register(channel, vec![], None, None).await;
let _ = router
.register_signature_key("discord", "not-valid-hex")
.await;
let stored = router.get_signature_key("discord").await;
assert!(stored.is_none(), "Invalid key should not be stored");
}
use axum::Router as AxumRouter;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::channels::wasm::router::create_wasm_channel_router;
use ed25519_dalek::{Signer, SigningKey};
async fn setup_discord_router() -> (Arc<WasmChannelRouter>, AxumRouter) {
let wasm_router = Arc::new(WasmChannelRouter::new());
let channel = create_test_channel("discord");
let endpoints = vec![RegisteredEndpoint {
channel_name: "discord".to_string(),
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
wasm_router.register(channel, endpoints, None, None).await;
let app = create_wasm_channel_router(wasm_router.clone(), None);
(wasm_router, app)
}
fn test_signing_key() -> SigningKey {
SigningKey::from_bytes(&[
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
0x1c, 0xae, 0x7f, 0x60,
])
}
#[tokio::test]
async fn test_webhook_rejects_missing_sig_headers() {
let (wasm_router, app) = setup_discord_router().await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.body(Body::from(r#"{"type":1}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Missing signature headers should return 401"
);
}
#[tokio::test]
async fn test_webhook_rejects_invalid_signature() {
let (wasm_router, app) = setup_discord_router().await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.header("x-signature-ed25519", "deadbeefdeadbeef")
.header("x-signature-timestamp", "1234567890")
.body(Body::from(r#"{"type":1}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Invalid signature should return 401"
);
}
#[tokio::test]
async fn test_webhook_accepts_valid_signature() {
let (wasm_router, app) = setup_discord_router().await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let timestamp = now_secs.to_string();
let body_bytes = br#"{"type":1}"#;
let mut message = Vec::new();
message.extend_from_slice(timestamp.as_bytes());
message.extend_from_slice(body_bytes);
let signature = signing_key.sign(&message);
let sig_hex = hex::encode(signature.to_bytes());
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.header("x-signature-ed25519", &sig_hex)
.header("x-signature-timestamp", ×tamp)
.body(Body::from(&body_bytes[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Valid signature should not return 401"
);
}
#[tokio::test]
async fn test_webhook_skips_sig_for_no_key() {
let (_wasm_router, app) = setup_discord_router().await;
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.body(Body::from(r#"{"type":1}"#))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"No signature key registered — should skip sig check"
);
}
#[tokio::test]
async fn test_webhook_sig_check_uses_body() {
let (wasm_router, app) = setup_discord_router().await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let timestamp = "1234567890";
let body_a = br#"{"type":1}"#;
let mut message = Vec::new();
message.extend_from_slice(timestamp.as_bytes());
message.extend_from_slice(body_a);
let signature = signing_key.sign(&message);
let sig_hex = hex::encode(signature.to_bytes());
let body_b = br#"{"type":2}"#;
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.header("x-signature-ed25519", &sig_hex)
.header("x-signature-timestamp", timestamp)
.body(Body::from(&body_b[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Signature for different body should return 401"
);
}
#[tokio::test]
async fn test_webhook_sig_check_uses_timestamp() {
let (wasm_router, app) = setup_discord_router().await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let timestamp_a = "1234567890";
let body = br#"{"type":1}"#;
let mut message = Vec::new();
message.extend_from_slice(timestamp_a.as_bytes());
message.extend_from_slice(body);
let signature = signing_key.sign(&message);
let sig_hex = hex::encode(signature.to_bytes());
let timestamp_b = "9999999999";
let req = Request::builder()
.method("POST")
.uri("/webhook/discord")
.header("content-type", "application/json")
.header("x-signature-ed25519", &sig_hex)
.header("x-signature-timestamp", timestamp_b)
.body(Body::from(&body[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Signature with mismatched timestamp should return 401"
);
}
#[tokio::test]
async fn test_webhook_sig_plus_secret() {
let wasm_router = Arc::new(WasmChannelRouter::new());
let channel = create_test_channel("discord");
let endpoints = vec![RegisteredEndpoint {
channel_name: "discord".to_string(),
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: true,
}];
wasm_router
.register(channel, endpoints, Some("my-secret".to_string()), None)
.await;
let signing_key = test_signing_key();
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
wasm_router
.register_signature_key("discord", &pub_key_hex)
.await
.unwrap();
let app = create_wasm_channel_router(wasm_router.clone(), None);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let timestamp = now_secs.to_string();
let body = br#"{"type":1}"#;
let mut message = Vec::new();
message.extend_from_slice(timestamp.as_bytes());
message.extend_from_slice(body);
let signature = signing_key.sign(&message);
let sig_hex = hex::encode(signature.to_bytes());
let req = Request::builder()
.method("POST")
.uri("/webhook/discord?secret=my-secret")
.header("content-type", "application/json")
.header("x-signature-ed25519", &sig_hex)
.header("x-signature-timestamp", ×tamp)
.body(Body::from(&body[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Valid secret + valid signature should not return 401"
);
}
async fn setup_slack_router() -> (Arc<WasmChannelRouter>, AxumRouter) {
let wasm_router = Arc::new(WasmChannelRouter::new());
let channel = create_test_channel("slack");
let endpoints = vec![RegisteredEndpoint {
channel_name: "slack".to_string(),
path: "/webhook/slack".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
wasm_router.register(channel, endpoints, None, None).await;
let app = create_wasm_channel_router(wasm_router.clone(), None);
(wasm_router, app)
}
fn slack_signature(signing_secret: &str, timestamp: &str, body: &[u8]) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut basestring = Vec::new();
basestring.extend_from_slice(b"v0:");
basestring.extend_from_slice(timestamp.as_bytes());
basestring.push(b':');
basestring.extend_from_slice(body);
let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes()).unwrap();
mac.update(&basestring);
let computed = mac.finalize().into_bytes();
format!("v0={}", hex::encode(computed))
}
#[tokio::test]
async fn test_webhook_hmac_rejects_missing_sig_headers() {
let (wasm_router, app) = setup_slack_router().await;
wasm_router
.register_hmac_secret("slack", "my-signing-secret")
.await;
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Missing HMAC signature headers should return 401"
);
}
#[tokio::test]
async fn test_webhook_hmac_rejects_invalid_signature() {
let (wasm_router, app) = setup_slack_router().await;
wasm_router
.register_hmac_secret("slack", "my-signing-secret")
.await;
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.header("x-slack-request-timestamp", "1234567890")
.header("x-slack-signature", "v0=deadbeefdeadbeef")
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Invalid HMAC signature should return 401"
);
}
#[tokio::test]
async fn test_webhook_hmac_accepts_valid_signature() {
let (wasm_router, app) = setup_slack_router().await;
let signing_secret = "my-signing-secret";
wasm_router
.register_hmac_secret("slack", signing_secret)
.await;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let timestamp = now_secs.to_string();
let body = b"token=xyzz0WbapA4vBCDEFasx0q6G";
let signature = slack_signature(signing_secret, ×tamp, body);
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.header("x-slack-request-timestamp", ×tamp)
.header("x-slack-signature", &signature)
.body(Body::from(&body[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Valid HMAC signature should not return 401"
);
}
#[tokio::test]
async fn test_webhook_hmac_skips_check_for_no_secret() {
let (_wasm_router, app) = setup_slack_router().await;
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_ne!(
resp.status(),
StatusCode::UNAUTHORIZED,
"No HMAC secret registered — should skip check"
);
}
#[tokio::test]
async fn test_webhook_hmac_uses_correct_body() {
let (wasm_router, app) = setup_slack_router().await;
let signing_secret = "my-signing-secret";
wasm_router
.register_hmac_secret("slack", signing_secret)
.await;
let timestamp = "1234567890";
let body_a = b"token=xyzz0WbapA4vBCDEFasx0q6G";
let body_b = b"token=MODIFIED";
let signature = slack_signature(signing_secret, timestamp, body_a);
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.header("x-slack-request-timestamp", timestamp)
.header("x-slack-signature", &signature)
.body(Body::from(&body_b[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Signature for different body should return 401"
);
}
#[tokio::test]
async fn test_webhook_hmac_uses_correct_timestamp() {
let (wasm_router, app) = setup_slack_router().await;
let signing_secret = "my-signing-secret";
wasm_router
.register_hmac_secret("slack", signing_secret)
.await;
let timestamp_a = "1234567890";
let timestamp_b = "9999999999";
let body = b"token=xyzz0WbapA4vBCDEFasx0q6G";
let signature = slack_signature(signing_secret, timestamp_a, body);
let req = Request::builder()
.method("POST")
.uri("/webhook/slack")
.header("content-type", "application/json")
.header("x-slack-request-timestamp", timestamp_b)
.header("x-slack-signature", &signature)
.body(Body::from(&body[..]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"Signature with mismatched timestamp should return 401"
);
}
}