diff --git a/Cargo.lock b/Cargo.lock index b4690b7f..3892d1a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2853,6 +2853,7 @@ dependencies = [ "futures", "hex", "hkdf", + "hmac", "html-to-markdown-rs", "http-body-util", "hyper 1.8.1", diff --git a/Cargo.toml b/Cargo.toml index 08e7347d..5cec54b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,6 +128,7 @@ wasmparser = "0.220" # WASM binary parsing for validation # Cryptography for secrets management aes-gcm = "0.10" hkdf = "0.12" +hmac = "0.12" sha2 = "0.10" blake3 = "1" rand = "0.8" diff --git a/channels-src/slack/slack.capabilities.json b/channels-src/slack/slack.capabilities.json index 4a6fc19c..60ef5319 100644 --- a/channels-src/slack/slack.capabilities.json +++ b/channels-src/slack/slack.capabilities.json @@ -44,6 +44,9 @@ "emit_rate_limit": { "messages_per_minute": 100, "messages_per_hour": 5000 + }, + "webhook": { + "hmac_secret_name": "slack_signing_secret" } } }, diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 2df7c469..5f5e80e7 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -277,6 +277,13 @@ impl LoadedChannel { .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())) } + /// Get the HMAC-SHA256 signing secret name from capabilities. + pub fn hmac_secret_name(&self) -> Option { + self.capabilities_file + .as_ref() + .and_then(|f| f.hmac_secret_name().map(|s| s.to_string())) + } + /// Get the webhook secret name from capabilities. pub fn webhook_secret_name(&self) -> String { self.capabilities_file diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 870bfc37..9b0f3da1 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -44,6 +44,8 @@ pub struct WasmChannelRouter { secret_headers: RwLock>, /// Ed25519 public keys for signature verification by channel name (hex-encoded). signature_keys: RwLock>, + /// HMAC-SHA256 signing secrets for signature verification by channel name (Slack-style). + hmac_secrets: RwLock>, } impl WasmChannelRouter { @@ -55,6 +57,7 @@ impl WasmChannelRouter { secrets: RwLock::new(HashMap::new()), secret_headers: RwLock::new(HashMap::new()), signature_keys: RwLock::new(HashMap::new()), + hmac_secrets: RwLock::new(HashMap::new()), } } @@ -134,6 +137,7 @@ impl WasmChannelRouter { 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); // Remove all paths for this channel self.path_to_channel @@ -208,6 +212,24 @@ impl WasmChannelRouter { pub async fn get_signature_key(&self, channel_name: &str) -> Option { self.signature_keys.read().await.get(channel_name).cloned() } + + /// Register an HMAC-SHA256 signing secret for signature verification. + /// + /// Channels with a registered secret will have Slack-style HMAC-SHA256 + /// signature validation performed before forwarding to WASM. + 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()); + } + + /// Get the HMAC signing secret for a channel. + /// + /// Returns `None` if no secret is registered (no HMAC check needed). + pub async fn get_hmac_secret(&self, channel_name: &str) -> Option { + self.hmac_secrets.read().await.get(channel_name).cloned() + } } impl Default for WasmChannelRouter { @@ -427,6 +449,57 @@ async fn webhook_handler( } } + // HMAC-SHA256 signature verification (Slack-style) + 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"); + } + _ => { + 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" + })), + ); + } + } + } + // Convert headers to HashMap let headers_map: HashMap = headers .iter() @@ -731,7 +804,59 @@ mod tests { assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); } - // ── Category 3: Router Signature Key Management ───────────────────── + // ── Category 3: Router HMAC Secret Management ─────────────────────── + + #[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; + + // Slack has no HMAC secret registered + 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; + + // Secret should exist + assert!(router.get_hmac_secret("slack").await.is_some()); + + // Unregister + router.unregister("slack").await; + + // Secret should be gone + assert!(router.get_hmac_secret("slack").await.is_none()); + } + + // ── Category 4: Router Signature Key Management ───────────────────── #[tokio::test] async fn test_register_and_get_signature_key() { @@ -1163,4 +1288,215 @@ mod tests { "Valid secret + valid signature should not return 401" ); } + + // ── HMAC-SHA256 Webhook Signature Tests ──────────────────────────── + + /// Helper to create a router with a registered channel at /webhook/slack. + async fn setup_slack_router() -> (Arc, 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) + } + + /// Helper: compute expected Slack signature for testing. + 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::::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; + + // Send request without HMAC signature headers + 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(); + // Should NOT be 401 — signature is valid (may be 500 since no WASM module) + 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; + + // No HMAC secret registered — should not require signature + 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(); + // Should NOT be 401 (may be 500 since no WASM module, but not auth failure) + 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"; + + // Sign body A + let signature = slack_signature(signing_secret, timestamp, body_a); + + // But send body B + 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"; + + // Sign with timestamp A + let signature = slack_signature(signing_secret, timestamp_a, body); + + // But send timestamp B in the header + 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" + ); + } } diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index 7e9d56f5..d1cbe705 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -154,6 +154,18 @@ impl ChannelCapabilitiesFile { .and_then(|w| w.signature_key_secret_name.as_deref()) } + /// Get the HMAC-SHA256 signing secret name for this channel. + /// + /// Returns the secret name declared in `webhook.hmac_secret_name`, + /// used to look up the HMAC signing secret in the secrets store (Slack-style). + pub fn hmac_secret_name(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.hmac_secret_name.as_deref()) + } + /// Get the webhook secret name for this channel. /// /// Returns the configured secret name or defaults to "{channel_name}_webhook_secret". @@ -278,6 +290,10 @@ pub struct WebhookSchema { /// for signature verification (e.g., Discord interaction verification). #[serde(default)] pub signature_key_secret_name: Option, + + /// Secret name in secrets store for HMAC-SHA256 signing (Slack-style). + #[serde(default)] + pub hmac_secret_name: Option, } /// Setup configuration schema. diff --git a/src/channels/wasm/signature.rs b/src/channels/wasm/signature.rs index 8ee33aaf..8b48d88c 100644 --- a/src/channels/wasm/signature.rs +++ b/src/channels/wasm/signature.rs @@ -1,9 +1,11 @@ -//! Discord Ed25519 signature verification. +//! Webhook signature verification (Discord Ed25519 and Slack HMAC-SHA256). //! -//! Validates `X-Signature-Ed25519` and `X-Signature-Timestamp` headers -//! on incoming Discord interaction webhooks, per Discord's security requirements. +//! Validates request signatures for incoming webhooks: +//! - Discord: `X-Signature-Ed25519` and `X-Signature-Timestamp` headers +//! - Slack: `X-Slack-Signature` and `X-Slack-Request-Timestamp` headers //! //! See: +//! See: /// Verify a Discord interaction signature. /// @@ -50,6 +52,60 @@ pub fn verify_discord_signature( verifying_key.verify_strict(&message, &signature).is_ok() } +/// Verify a Slack webhook signature using HMAC-SHA256. +/// +/// Slack signs each webhook request with HMAC-SHA256 using: +/// - basestring = `"v0:" + timestamp + ":" + body` +/// - signature = hex-encoded HMAC-SHA256(signing_secret, basestring) +/// - header = `"v0=" + signature` (in `X-Slack-Signature` header) +/// +/// Includes staleness check: rejects requests with timestamps older than 5 minutes. +/// Returns `true` if the signature is valid, `false` on any error +/// (bad timing, mismatched signature, invalid format, etc.). +pub fn verify_slack_signature( + signing_secret: &str, + timestamp: &str, + body: &[u8], + signature_header: &str, + now_secs: i64, +) -> bool { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + // 1. Parse and check staleness (5-minute window) + let ts: i64 = match timestamp.parse() { + Ok(v) => v, + Err(_) => return false, + }; + if (now_secs - ts).abs() > 300 { + return false; + } + + // 2. Build the basestring: "v0:{timestamp}:{body}" + let mut basestring = Vec::with_capacity(3 + timestamp.len() + 1 + body.len()); + basestring.extend_from_slice(b"v0:"); + basestring.extend_from_slice(timestamp.as_bytes()); + basestring.push(b':'); + basestring.extend_from_slice(body); + + // 3. Compute HMAC-SHA256 + let mut mac = match Hmac::::new_from_slice(signing_secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(&basestring); + let computed = mac.finalize().into_bytes(); + let computed_hex = hex::encode(computed); + let expected = format!("v0={}", computed_hex); + + // 4. Constant-time compare (avoids timing side-channels) + use subtle::ConstantTimeEq; + expected + .as_bytes() + .ct_eq(signature_header.as_bytes()) + .into() +} + #[cfg(test)] mod tests { use super::*; @@ -338,4 +394,264 @@ mod tests { "Negative timestamp should be rejected" ); } + + // ── Category: HMAC-SHA256 Signature Verification (Slack) ──────────── + + /// Helper: compute expected Slack signature for a given secret, timestamp, and body. + fn sign_slack_message(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::::new_from_slice(signing_secret.as_bytes()).unwrap(); + mac.update(&basestring); + let computed = mac.finalize().into_bytes(); + format!("v0={}", hex::encode(computed)) + } + + const SLACK_TEST_TS: i64 = 1234567890; + + #[test] + fn test_slack_valid_signature_succeeds() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + assert!(verify_slack_signature( + signing_secret, + timestamp, + body, + &signature, + SLACK_TEST_TS + )); + } + + #[test] + fn test_slack_tampered_body_fails() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let original_body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J"; + let tampered_body = b"token=MODIFIED&team_id=T1DC2JH3J"; + + let signature = sign_slack_message(signing_secret, timestamp, original_body); + assert!( + !verify_slack_signature( + signing_secret, + timestamp, + tampered_body, + &signature, + SLACK_TEST_TS + ), + "Signature for different body should fail" + ); + } + + #[test] + fn test_slack_tampered_timestamp_fails() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + assert!( + !verify_slack_signature( + signing_secret, + "9999999999", // Different timestamp in signature + body, + &signature, + SLACK_TEST_TS + ), + "Signature with wrong timestamp should fail" + ); + } + + #[test] + fn test_slack_tampered_signature_fails() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // Flip a byte in the signature hex (change first char after "v0=") + let chars: Vec = signature.chars().collect(); + let mut new_chars = chars.clone(); + if chars.len() > 3 { + new_chars[3] = if chars[3] == 'a' { 'b' } else { 'a' }; + } + let modified_sig: String = new_chars.iter().collect(); + + assert!( + !verify_slack_signature( + signing_secret, + timestamp, + body, + &modified_sig, + SLACK_TEST_TS + ), + "Tampered signature should fail" + ); + } + + #[test] + fn test_slack_stale_timestamp_rejected() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // now_secs is 400 seconds after timestamp — too stale + assert!( + !verify_slack_signature( + signing_secret, + timestamp, + body, + &signature, + SLACK_TEST_TS + 400 + ), + "Stale timestamp (400s old) should be rejected" + ); + } + + #[test] + fn test_slack_future_timestamp_rejected() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // now_secs is 400 seconds before timestamp — future + assert!( + !verify_slack_signature( + signing_secret, + timestamp, + body, + &signature, + SLACK_TEST_TS - 400 + ), + "Future timestamp (400s ahead) should be rejected" + ); + } + + #[test] + fn test_slack_boundary_300s_accepted() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // Exactly 300 seconds difference — should be accepted + assert!( + verify_slack_signature( + signing_secret, + timestamp, + body, + &signature, + SLACK_TEST_TS + 300 + ), + "Timestamp exactly 300s old should be accepted" + ); + } + + #[test] + fn test_slack_boundary_301s_rejected() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // 301 seconds difference — should be rejected + assert!( + !verify_slack_signature( + signing_secret, + timestamp, + body, + &signature, + SLACK_TEST_TS + 301 + ), + "Timestamp 301s old should be rejected" + ); + } + + #[test] + fn test_slack_non_numeric_timestamp_rejected() { + let signing_secret = "my-signing-secret"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + assert!( + !verify_slack_signature(signing_secret, "not-a-number", body, "v0=abc123", 0), + "Non-numeric timestamp should be rejected" + ); + } + + #[test] + fn test_slack_missing_v0_prefix_fails() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(signing_secret, timestamp, body); + // Remove the "v0=" prefix + let bad_sig = signature.strip_prefix("v0=").unwrap_or(&signature); + + assert!( + !verify_slack_signature(signing_secret, timestamp, body, bad_sig, SLACK_TEST_TS), + "Missing v0= prefix should fail" + ); + } + + #[test] + fn test_slack_wrong_signing_secret_fails() { + let secret_a = "secret-a"; + let secret_b = "secret-b"; + let timestamp = "1234567890"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + let signature = sign_slack_message(secret_a, timestamp, body); + // Try to verify with a different secret + assert!( + !verify_slack_signature(secret_b, timestamp, body, &signature, SLACK_TEST_TS), + "Signature from different secret should fail" + ); + } + + #[test] + fn test_slack_empty_body_valid() { + let signing_secret = "my-signing-secret"; + let timestamp = "1234567890"; + let body = b""; + + let signature = sign_slack_message(signing_secret, timestamp, body); + assert!( + verify_slack_signature(signing_secret, timestamp, body, &signature, SLACK_TEST_TS), + "Empty body with valid signature should succeed" + ); + } + + #[test] + fn test_slack_negative_timestamp_rejected() { + let signing_secret = "my-signing-secret"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + assert!( + !verify_slack_signature(signing_secret, "-1", body, "v0=abc123", 0), + "Negative timestamp should be rejected" + ); + } + + #[test] + fn test_slack_empty_timestamp_rejected() { + let signing_secret = "my-signing-secret"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G"; + + assert!( + !verify_slack_signature(signing_secret, "", body, "v0=abc123", 0), + "Empty timestamp should be rejected" + ); + } } diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index ff1185b9..c3e77e0d 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -2397,6 +2397,7 @@ impl ExtensionManager { let webhook_secret_name = loaded.webhook_secret_name(); let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); let sig_key_secret_name = loaded.signature_key_secret_name(); + let hmac_secret_name = loaded.hmac_secret_name(); // Get webhook secret from secrets store let webhook_secret = self @@ -2480,6 +2481,21 @@ impl ExtensionManager { } } } + + // Register HMAC signing secret if declared in capabilities + if let Some(hmac_name) = &hmac_secret_name { + match self.secrets.get_decrypted(&self.user_id, hmac_name).await { + Ok(secret) => { + wasm_channel_router + .register_hmac_secret(&channel_name, secret.expose()) + .await; + tracing::info!(channel = %channel_name, "Registered HMAC signing secret for hot-activated channel"); + } + Err(e) => { + tracing::warn!(channel = %channel_name, error = %e, "HMAC secret not found"); + } + } + } } // Inject credentials @@ -2587,19 +2603,30 @@ impl ExtensionManager { } }; - // Also refresh the webhook secret in the router - // Load capabilities file to get the correct secret name (may be overridden) - let webhook_secret_name = { - let cap_path = self - .wasm_channels_dir - .join(format!("{}.capabilities.json", name)); - match tokio::fs::read(&cap_path).await { - Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes) - .map(|f| f.webhook_secret_name()) - .unwrap_or_else(|_| format!("{}_webhook_secret", name)), - Err(_) => format!("{}_webhook_secret", name), - } + // Load capabilities file once to extract all secret names + let cap_path = self + .wasm_channels_dir + .join(format!("{}.capabilities.json", name)); + let capabilities_file = match tokio::fs::read(&cap_path).await { + Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes).ok(), + Err(_) => None, }; + + // Extract all secret names from the capabilities file + let webhook_secret_name = capabilities_file + .as_ref() + .map(|f| f.webhook_secret_name()) + .unwrap_or_else(|| format!("{}_webhook_secret", name)); + + let sig_key_secret_name = capabilities_file + .as_ref() + .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())); + + let hmac_secret_name = capabilities_file + .as_ref() + .and_then(|f| f.hmac_secret_name().map(|s| s.to_string())); + + // Refresh webhook secret if let Ok(secret) = self .secrets .get_decrypted(&self.user_id, &webhook_secret_name) @@ -2618,18 +2645,7 @@ impl ExtensionManager { existing_channel.update_config(config_updates).await; } - // Also refresh signature key in the router - let sig_key_secret_name = { - let cap_path = self - .wasm_channels_dir - .join(format!("{}.capabilities.json", name)); - match tokio::fs::read(&cap_path).await { - Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes) - .ok() - .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())), - Err(_) => None, - } - }; + // Refresh signature key if let Some(ref sig_key_name) = sig_key_secret_name && let Ok(key_secret) = self .secrets @@ -2649,6 +2665,23 @@ impl ExtensionManager { } } + // Refresh HMAC signing secret + if let Some(ref hmac_secret_name_ref) = hmac_secret_name { + match self + .secrets + .get_decrypted(&self.user_id, hmac_secret_name_ref) + .await + { + Ok(secret) => { + router.register_hmac_secret(name, secret.expose()).await; + tracing::info!(channel = %name, "Refreshed HMAC signing secret"); + } + Err(e) => { + tracing::warn!(channel = %name, error = %e, "HMAC secret not found"); + } + } + } + // Refresh tunnel_url in case it wasn't set at startup if let Some(ref tunnel_url) = self.tunnel_url { let mut config_updates = std::collections::HashMap::new(); diff --git a/src/main.rs b/src/main.rs index 82b5ebd5..84d12912 100644 --- a/src/main.rs +++ b/src/main.rs @@ -950,6 +950,7 @@ async fn setup_wasm_channels( let secret_name = loaded.webhook_secret_name(); let sig_key_secret_name = loaded.signature_key_secret_name(); + let hmac_secret_name = loaded.hmac_secret_name(); let webhook_secret = if let Some(secrets) = secrets_store { secrets @@ -1044,6 +1045,17 @@ async fn setup_wasm_channels( } } + // Register HMAC signing secret if declared in capabilities + if let Some(ref hmac_secret_name) = hmac_secret_name + && let Some(secrets) = secrets_store + && let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await + { + wasm_router + .register_hmac_secret(&channel_name, secret.expose()) + .await; + tracing::info!(channel = %channel_name, "Registered HMAC signing secret"); + } + if let Some(secrets) = secrets_store { match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await { Ok(count) => {