mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: Add HMAC-SHA256 webhook signature validation for Slack (#588)
* feat: Add HMAC-SHA256 webhook signature validation for Slack * review fixes
This commit is contained in:
Generated
+1
@@ -2853,6 +2853,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"html-to-markdown-rs",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
"emit_rate_limit": {
|
||||
"messages_per_minute": 100,
|
||||
"messages_per_hour": 5000
|
||||
},
|
||||
"webhook": {
|
||||
"hmac_secret_name": "slack_signing_secret"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<String> {
|
||||
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
|
||||
|
||||
+337
-1
@@ -44,6 +44,8 @@ pub struct WasmChannelRouter {
|
||||
secret_headers: RwLock<HashMap<String, String>>,
|
||||
/// Ed25519 public keys for signature verification by channel name (hex-encoded).
|
||||
signature_keys: RwLock<HashMap<String, String>>,
|
||||
/// HMAC-SHA256 signing secrets for signature verification by channel name (Slack-style).
|
||||
hmac_secrets: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String, String> = 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<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)
|
||||
}
|
||||
|
||||
/// 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::<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;
|
||||
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Secret name in secrets store for HMAC-SHA256 signing (Slack-style).
|
||||
#[serde(default)]
|
||||
pub hmac_secret_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Setup configuration schema.
|
||||
|
||||
@@ -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: <https://discord.com/developers/docs/interactions/overview#validating-security-request-headers>
|
||||
//! See: <https://api.slack.com/authentication/verifying-requests-from-slack>
|
||||
|
||||
/// 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::<Sha256>::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::<Sha256>::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<char> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+54
-21
@@ -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 = {
|
||||
// Load capabilities file once to extract all secret names
|
||||
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),
|
||||
}
|
||||
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();
|
||||
|
||||
+12
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user