diff --git a/Cargo.lock b/Cargo.lock index 27acbe71..08f3b968 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -492,6 +492,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -949,6 +955,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -1353,6 +1365,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + [[package]] name = "darling" version = "0.21.3" @@ -1438,6 +1477,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.6" @@ -1607,6 +1656,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.10.0" @@ -1773,6 +1846,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.27" @@ -2719,9 +2798,11 @@ dependencies = [ "deadpool-postgres", "dirs 6.0.0", "dotenvy", + "ed25519-dalek", "flate2", "fs4", "futures", + "hex", "hkdf", "html-to-markdown-rs", "http-body-util", @@ -3814,6 +3895,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -5104,6 +5195,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -5157,6 +5257,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sptr" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 31581129..0eae3325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,6 +154,8 @@ lru = "0.16.3" # HTML to Markdown conversion (feature gated) html-to-markdown-rs = { version = "2.3", optional = true } readabilityrs = { version = "0.1.2", optional = true } +ed25519-dalek = { version = "2.2.0", features = ["std"] } +hex = "0.4.3" # macOS keychain [target.'cfg(target_os = "macos")'.dependencies] diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index 17f9c0d0..f96d95e4 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -8,6 +8,11 @@ "name": "discord_bot_token", "prompt": "Enter your Discord Bot Token (from Developer Portal)", "optional": false + }, + { + "name": "discord_public_key", + "prompt": "Enter your Discord Application Public Key (from Developer Portal > General Information)", + "optional": false } ] }, @@ -39,6 +44,9 @@ "emit_rate_limit": { "messages_per_minute": 100, "messages_per_hour": 5000 + }, + "webhook": { + "signature_key_secret_name": "discord_public_key" } } }, diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 3f7fdb63..710c108d 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -248,6 +248,13 @@ impl LoadedChannel { .and_then(|f| f.webhook_secret_header()) } + /// Get the signature verification key secret name from capabilities. + pub fn signature_key_secret_name(&self) -> Option { + self.capabilities_file + .as_ref() + .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())) + } + /// Get the webhook secret name from capabilities. pub fn webhook_secret_name(&self) -> String { self.capabilities_file @@ -416,6 +423,18 @@ mod tests { assert!(channels.contains_key("channel")); } + #[test] + fn test_loaded_channel_signature_key_none_without_caps() { + // We can't easily construct a WasmChannel without a runtime, so test + // the delegation logic directly: when capabilities_file is None, the + // chain returns None (same logic as LoadedChannel::signature_key_secret_name). + let cap_file: Option = None; + let result = cap_file + .as_ref() + .and_then(|f| f.signature_key_secret_name().map(|s| s.to_string())); + assert_eq!(result, None); + } + #[tokio::test] async fn test_loader_invalid_name() { let config = WasmChannelRuntimeConfig::for_testing(); diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 17ac7726..7c74d1aa 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -86,6 +86,7 @@ mod loader; mod router; mod runtime; mod schema; +pub(crate) mod signature; mod wrapper; // Core types diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 4fc20f3a..99309c36 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -42,6 +42,8 @@ pub struct WasmChannelRouter { secrets: RwLock>, /// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token"). secret_headers: RwLock>, + /// Ed25519 public keys for signature verification by channel name (hex-encoded). + signature_keys: RwLock>, } impl WasmChannelRouter { @@ -52,6 +54,7 @@ impl WasmChannelRouter { path_to_channel: RwLock::new(HashMap::new()), secrets: RwLock::new(HashMap::new()), secret_headers: RwLock::new(HashMap::new()), + signature_keys: RwLock::new(HashMap::new()), } } @@ -130,6 +133,7 @@ impl WasmChannelRouter { 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); // Remove all paths for this channel self.path_to_channel @@ -174,6 +178,36 @@ impl WasmChannelRouter { pub async fn list_paths(&self) -> Vec { self.path_to_channel.read().await.keys().cloned().collect() } + + /// Register an Ed25519 public key for signature verification. + /// + /// Validates that the key is valid hex encoding of a 32-byte Ed25519 public key. + /// Channels with a registered key will have Discord-style Ed25519 + /// signature validation performed before forwarding to WASM. + 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(()) + } + + /// Get the signature verification key for a channel. + /// + /// Returns `None` if no key is registered (no signature check needed). + pub async fn get_signature_key(&self, channel_name: &str) -> Option { + self.signature_keys.read().await.get(channel_name).cloned() + } } impl Default for WasmChannelRouter { @@ -342,6 +376,57 @@ async fn webhook_handler( } } + // Ed25519 signature verification (Discord-style) + 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"); + } + _ => { + tracing::warn!( + channel = %channel_name, + "Signature headers missing but key is registered" + ); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Missing signature headers" + })), + ); + } + } + } + // Convert headers to HashMap let headers_map: HashMap = headers .iter() @@ -644,4 +729,437 @@ mod tests { .await; assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); } + + // ── Category 3: Router Signature Key Management ───────────────────── + + #[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; + + // Slack has no signature key registered + 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; + // Use a valid 32-byte Ed25519 key for this test + let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; + router + .register_signature_key("discord", valid_key) + .await + .unwrap(); + + // Key should exist + assert!(router.get_signature_key("discord").await.is_some()); + + // Unregister + router.unregister("discord").await; + + // Key should be gone + assert!(router.get_signature_key("discord").await.is_none()); + } + + // ── Key Validation Tests ────────────────────────────────────────── + + #[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; + + // Valid 32-byte Ed25519 public key (from test keypair) + 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; + + // 16 bytes instead of 32 + 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; + + // Attempt to register invalid key + let _ = router + .register_signature_key("discord", "not-valid-hex") + .await; + + // Should not have stored anything + let stored = router.get_signature_key("discord").await; + assert!(stored.is_none(), "Invalid key should not be stored"); + } + + // ── Webhook Handler Integration Tests ───────────────────────────── + + 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}; + + /// Helper to create a router with a registered channel at /webhook/discord. + async fn setup_discord_router() -> (Arc, 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) + } + + /// Helper: generate a test keypair. + 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; + + // Register a signature key + 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(); + + // Send request without signature headers + 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(); + + // Use current timestamp so staleness check passes + 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(); + // Should NOT be 401 — signature is valid (may be 500 since no WASM module) + 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; + + // No signature key registered — should not require signature + 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(); + // Should NOT be 401 (may be 500 since no WASM module, but not auth failure) + 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"; + // Sign body A + 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()); + + // But send body B + 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(); + + // Sign with timestamp A + 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()); + + // But send timestamp B in the header + 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, + }]; + + // Register with BOTH secret and signature key + 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); + + // Use current timestamp so staleness check passes + 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()); + + // Provide valid signature AND valid secret + 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(); + // Should pass both checks (may be 500 due to no WASM module, but not 401) + assert_ne!( + resp.status(), + StatusCode::UNAUTHORIZED, + "Valid secret + valid signature should not return 401" + ); + } } diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index f0769dee..8741be12 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -111,6 +111,18 @@ impl ChannelCapabilitiesFile { .and_then(|w| w.secret_header.as_deref()) } + /// Get the signature verification key secret name for this channel. + /// + /// Returns the secret name declared in `webhook.signature_key_secret_name`, + /// used to look up the Ed25519 public key in the secrets store. + pub fn signature_key_secret_name(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.signature_key_secret_name.as_deref()) + } + /// Get the webhook secret name for this channel. /// /// Returns the configured secret name or defaults to "{channel_name}_webhook_secret". @@ -230,6 +242,11 @@ pub struct WebhookSchema { /// Default: "{channel_name}_webhook_secret" #[serde(default)] pub secret_name: Option, + + /// Secret name in secrets store containing the Ed25519 public key + /// for signature verification (e.g., Discord interaction verification). + #[serde(default)] + pub signature_key_secret_name: Option, } /// Setup configuration schema. @@ -585,4 +602,90 @@ mod tests { 64 ); } + + // ── Category 5: Discord Capabilities Setup & Configuration ────────── + + #[test] + fn test_discord_capabilities_has_public_key_secret() { + let json = include_str!("../../../channels-src/discord/discord.capabilities.json"); + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + + let secret_names: Vec<&str> = file + .setup + .required_secrets + .iter() + .map(|s| s.name.as_str()) + .collect(); + + assert!( + secret_names.contains(&"discord_public_key"), + "discord.capabilities.json must include discord_public_key in setup.required_secrets, \ + found: {:?}", + secret_names + ); + } + + #[test] + fn test_webhook_schema_signature_key_secret_name() { + let json = r#"{ + "name": "discord", + "capabilities": { + "channel": { + "allowed_paths": ["/webhook/discord"], + "webhook": { + "signature_key_secret_name": "discord_public_key" + } + } + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + assert_eq!(file.signature_key_secret_name(), Some("discord_public_key")); + } + + #[test] + fn test_signature_key_secret_name_none_when_missing() { + let json = r#"{ + "name": "telegram", + "capabilities": { + "channel": { + "allowed_paths": ["/webhook/telegram"], + "webhook": { + "secret_header": "X-Telegram-Bot-Api-Secret-Token" + } + } + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + assert_eq!(file.signature_key_secret_name(), None); + } + + #[test] + fn test_discord_capabilities_signature_key() { + let json = include_str!("../../../channels-src/discord/discord.capabilities.json"); + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + assert_eq!( + file.signature_key_secret_name(), + Some("discord_public_key"), + "discord.capabilities.json must declare signature_key_secret_name" + ); + } + + #[test] + fn test_discord_capabilities_secrets_allowlist() { + let json = include_str!("../../../channels-src/discord/discord.capabilities.json"); + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + + let caps = file.to_capabilities(); + let secrets_caps = caps + .tool_capabilities + .secrets + .expect("Discord should have secrets capability"); + + assert!( + secrets_caps.is_allowed("discord_public_key"), + "discord_public_key must be in the secrets allowlist" + ); + } } diff --git a/src/channels/wasm/signature.rs b/src/channels/wasm/signature.rs new file mode 100644 index 00000000..8ee33aaf --- /dev/null +++ b/src/channels/wasm/signature.rs @@ -0,0 +1,341 @@ +//! Discord Ed25519 signature verification. +//! +//! Validates `X-Signature-Ed25519` and `X-Signature-Timestamp` headers +//! on incoming Discord interaction webhooks, per Discord's security requirements. +//! +//! See: + +/// Verify a Discord interaction signature. +/// +/// Discord signs each interaction with Ed25519 using: +/// - message = `timestamp` (UTF-8 bytes) ++ `body` (raw bytes) +/// - signature = Ed25519 detached signature (hex-encoded in header) +/// - public_key = Application public key from Developer Portal (hex-encoded) +/// +/// Returns `true` if the signature is valid, `false` on any error +/// (bad hex, wrong length, invalid signature, etc.). +pub fn verify_discord_signature( + public_key_hex: &str, + signature_hex: &str, + timestamp: &str, + body: &[u8], + now_secs: i64, +) -> bool { + // Staleness check: reject non-numeric or stale/future timestamps + let ts: i64 = match timestamp.parse() { + Ok(v) => v, + Err(_) => return false, + }; + if (now_secs - ts).abs() > 5 { + return false; + } + use ed25519_dalek::{Signature, VerifyingKey}; + + let Ok(sig_bytes) = hex::decode(signature_hex) else { + return false; + }; + let Ok(key_bytes) = hex::decode(public_key_hex) else { + return false; + }; + let Ok(signature) = Signature::from_slice(&sig_bytes) else { + return false; + }; + let Ok(verifying_key) = VerifyingKey::try_from(key_bytes.as_slice()) else { + return false; + }; + + let mut message = Vec::with_capacity(timestamp.len() + body.len()); + message.extend_from_slice(timestamp.as_bytes()); + message.extend_from_slice(body); + verifying_key.verify_strict(&message, &signature).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + /// Helper: generate a test keypair and produce a valid signature for the given timestamp+body. + fn sign_test_message(timestamp: &str, body: &[u8]) -> (String, String, String) { + let signing_key = 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, + ]); + let verifying_key = signing_key.verifying_key(); + + let mut message = Vec::new(); + message.extend_from_slice(timestamp.as_bytes()); + message.extend_from_slice(body); + + let signature = signing_key.sign(&message); + + let public_key_hex = hex::encode(verifying_key.to_bytes()); + let signature_hex = hex::encode(signature.to_bytes()); + + (public_key_hex, signature_hex, timestamp.to_string()) + } + + // ── Category 2: Ed25519 Signature Verification ────────────────────── + + /// Existing tests pass `now_secs` matching their hardcoded timestamp + /// so they continue testing crypto-only behavior. + const TEST_TS: i64 = 1234567890; + + #[test] + fn test_valid_signature_succeeds() { + let timestamp = "1234567890"; + let body = b"test body content"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + + assert!( + verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS), + "Valid signature should verify successfully" + ); + } + + #[test] + fn test_invalid_signature_fails() { + let timestamp = "1234567890"; + let body = b"test body content"; + let (pub_key, mut sig, ts) = sign_test_message(timestamp, body); + + // Tamper one byte of the signature + let mut sig_bytes = hex::decode(&sig).unwrap(); + sig_bytes[0] ^= 0xff; + sig = hex::encode(&sig_bytes); + + assert!( + !verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS), + "Tampered signature should fail verification" + ); + } + + #[test] + fn test_tampered_body_fails() { + let timestamp = "1234567890"; + let body = b"original body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + + let tampered_body = b"tampered body"; + assert!( + !verify_discord_signature(&pub_key, &sig, &ts, tampered_body, TEST_TS), + "Signature for different body should fail" + ); + } + + #[test] + fn test_tampered_timestamp_fails() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, _ts) = sign_test_message(timestamp, body); + + assert!( + !verify_discord_signature(&pub_key, &sig, "9999999999", body, TEST_TS), + "Signature with wrong timestamp should fail" + ); + } + + #[test] + fn test_invalid_hex_signature_fails() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, _sig, ts) = sign_test_message(timestamp, body); + + assert!( + !verify_discord_signature(&pub_key, "not-valid-hex-zzz", &ts, body, TEST_TS), + "Non-hex signature should fail gracefully" + ); + } + + #[test] + fn test_invalid_hex_public_key_fails() { + let timestamp = "1234567890"; + let body = b"test body"; + let (_pub_key, sig, ts) = sign_test_message(timestamp, body); + + assert!( + !verify_discord_signature("not-valid-hex-zzz", &sig, &ts, body, TEST_TS), + "Non-hex public key should fail gracefully" + ); + } + + #[test] + fn test_wrong_length_signature_fails() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, _sig, ts) = sign_test_message(timestamp, body); + + // Too short (only 32 bytes instead of 64) + let short_sig = hex::encode([0u8; 32]); + assert!( + !verify_discord_signature(&pub_key, &short_sig, &ts, body, TEST_TS), + "Short signature should fail" + ); + } + + #[test] + fn test_wrong_length_public_key_fails() { + let timestamp = "1234567890"; + let body = b"test body"; + let (_pub_key, sig, ts) = sign_test_message(timestamp, body); + + // Too short (only 16 bytes instead of 32) + let short_key = hex::encode([0u8; 16]); + assert!( + !verify_discord_signature(&short_key, &sig, &ts, body, TEST_TS), + "Short public key should fail" + ); + } + + #[test] + fn test_empty_body_valid_signature() { + let timestamp = "1234567890"; + let body = b""; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + + assert!( + verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS), + "Empty body with valid signature should succeed" + ); + } + + #[test] + fn test_discord_reference_vector() { + // Hardcoded test vector using the RFC 8032 test key + // This ensures the implementation matches the standard Ed25519 algorithm + let signing_key = SigningKey::from_bytes(&[ + 0xc5, 0xaa, 0x8d, 0xf4, 0x3f, 0x9f, 0x83, 0x7b, 0xed, 0xb7, 0x44, 0x2f, 0x31, 0xdc, + 0xb7, 0xb1, 0x66, 0xd3, 0x85, 0x35, 0x07, 0x6f, 0x09, 0x4b, 0x85, 0xce, 0x3a, 0x2e, + 0x0b, 0x44, 0x58, 0xf7, + ]); + let verifying_key = signing_key.verifying_key(); + let public_key_hex = hex::encode(verifying_key.to_bytes()); + + let timestamp = "1609459200"; + let now_secs: i64 = 1609459200; + let body = br#"{"type":1}"#; // Discord PING + + let mut message = Vec::new(); + message.extend_from_slice(timestamp.as_bytes()); + message.extend_from_slice(body); + + let signature = signing_key.sign(&message); + let signature_hex = hex::encode(signature.to_bytes()); + + assert!( + verify_discord_signature(&public_key_hex, &signature_hex, timestamp, body, now_secs), + "Reference vector should verify" + ); + + // Same key, but tampered body should fail + assert!( + !verify_discord_signature( + &public_key_hex, + &signature_hex, + timestamp, + br#"{"type":2}"#, + now_secs + ), + "Reference vector with tampered body should fail" + ); + } + + // ── Category: Timestamp Staleness ───────────────────────────────── + + #[test] + fn test_stale_timestamp_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + // now_secs is 100 seconds after the timestamp — too stale + assert!( + !verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 100), + "Stale timestamp (100s old) should be rejected" + ); + } + + #[test] + fn test_future_timestamp_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + // now_secs is 100 seconds before the timestamp — future + assert!( + !verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS - 100), + "Future timestamp (100s ahead) should be rejected" + ); + } + + #[test] + fn test_fresh_timestamp_accepted() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + // now_secs matches exactly — fresh + assert!( + verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS), + "Fresh timestamp (0s difference) should be accepted" + ); + } + + #[test] + fn test_non_numeric_timestamp_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, _ts) = sign_test_message(timestamp, body); + // Pass a non-numeric timestamp string + assert!( + !verify_discord_signature(&pub_key, &sig, "not-a-number", body, 0), + "Non-numeric timestamp should be rejected" + ); + } + + #[test] + fn test_empty_timestamp_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, _ts) = sign_test_message(timestamp, body); + // Pass an empty timestamp string + assert!( + !verify_discord_signature(&pub_key, &sig, "", body, 0), + "Empty timestamp should be rejected" + ); + } + + #[test] + fn test_boundary_5s_accepted() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + // Exactly 5 seconds difference — should be accepted (> 5, not >= 5) + assert!( + verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 5), + "Timestamp exactly 5s old should be accepted" + ); + } + + #[test] + fn test_boundary_6s_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, ts) = sign_test_message(timestamp, body); + // 6 seconds difference — should be rejected + assert!( + !verify_discord_signature(&pub_key, &sig, &ts, body, TEST_TS + 6), + "Timestamp 6s old should be rejected" + ); + } + + #[test] + fn test_negative_timestamp_rejected() { + let timestamp = "1234567890"; + let body = b"test body"; + let (pub_key, sig, _ts) = sign_test_message(timestamp, body); + // Pass a negative timestamp string + assert!( + !verify_discord_signature(&pub_key, &sig, "-1", body, TEST_TS), + "Negative timestamp should be rejected" + ); + } +} diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 9f3ad6d5..1adbca18 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -1796,6 +1796,7 @@ impl ExtensionManager { let channel_name = loaded.name().to_string(); 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(); // Get webhook secret from secrets store let webhook_secret = self @@ -1861,6 +1862,26 @@ impl ExtensionManager { ) .await; tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router"); + + // Register Ed25519 signature key if declared in capabilities + if let Some(ref sig_key_name) = sig_key_secret_name + && let Ok(key_secret) = self + .secrets + .get_decrypted(&self.user_id, sig_key_name) + .await + { + match wasm_channel_router + .register_signature_key(&channel_name, key_secret.expose()) + .await + { + Ok(()) => { + tracing::info!(channel = %channel_name, "Registered signature key for hot-activated channel") + } + Err(e) => { + tracing::error!(channel = %channel_name, error = %e, "Failed to register signature key") + } + } + } } // Inject credentials @@ -1996,6 +2017,37 @@ 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, + } + }; + if let Some(ref sig_key_name) = sig_key_secret_name + && let Ok(key_secret) = self + .secrets + .get_decrypted(&self.user_id, sig_key_name) + .await + { + match router + .register_signature_key(name, key_secret.expose()) + .await + { + Ok(()) => { + tracing::info!(channel = %name, "Refreshed signature verification key") + } + Err(e) => { + tracing::error!(channel = %name, error = %e, "Failed to refresh signature key") + } + } + } + // 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 802db8d7..1b3877c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -896,6 +896,7 @@ async fn setup_wasm_channels( tracing::info!("Loaded WASM channel: {}", channel_name); let secret_name = loaded.webhook_secret_name(); + let sig_key_secret_name = loaded.signature_key_secret_name(); let webhook_secret = if let Some(secrets) = secrets_store { secrets @@ -969,6 +970,25 @@ async fn setup_wasm_channels( secret_header, ) .await; + + // Register Ed25519 signature key if declared in capabilities + if let Some(ref sig_key_name) = sig_key_secret_name + && let Some(secrets) = secrets_store + && let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await + { + match wasm_router + .register_signature_key(&channel_name, key_secret.expose()) + .await + { + Ok(()) => { + tracing::info!(channel = %channel_name, "Registered Ed25519 signature key") + } + Err(e) => { + tracing::error!(channel = %channel_name, error = %e, "Invalid signature key in secrets store") + } + } + } + if let Some(secrets) = secrets_store { match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await { Ok(count) => { diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index fe201c34..f03f6f2c 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -61,17 +61,42 @@ pub struct CapabilitiesFile { /// Used by `ironclaw config` to guide users through auth setup. #[serde(default)] pub auth: Option, + + /// Nested capabilities wrapper for channel-level JSON compatibility. + /// + /// Channel capabilities files nest tool capabilities under a `"capabilities"` key. + /// This allows `from_json()`/`from_bytes()` to also parse channel-level JSON; + /// inner fields are promoted into top-level fields by `resolve_nested()`. + /// Always `None` after construction via the public parse methods. + #[serde(default, skip_serializing)] + pub capabilities: Option>, } impl CapabilitiesFile { /// Parse from JSON string. pub fn from_json(json: &str) -> Result { - serde_json::from_str(json) + serde_json::from_str::(json).map(Self::resolve_nested) } /// Parse from JSON bytes. pub fn from_bytes(bytes: &[u8]) -> Result { - serde_json::from_slice(bytes) + serde_json::from_slice::(bytes).map(Self::resolve_nested) + } + + /// Merge nested `capabilities` wrapper into top-level fields. + /// + /// Channel-level JSON nests tool capabilities under `"capabilities"`. + /// This promotes the inner fields so callers can access them uniformly. + fn resolve_nested(mut self) -> Self { + if let Some(inner) = self.capabilities.take() { + let inner = inner.resolve_nested(); + self.http = self.http.or(inner.http); + self.secrets = self.secrets.or(inner.secrets); + self.tool_invoke = self.tool_invoke.or(inner.tool_invoke); + self.workspace = self.workspace.or(inner.workspace); + self.auth = self.auth.or(inner.auth); + } + self } /// Convert to runtime Capabilities. @@ -234,6 +259,7 @@ pub enum CredentialLocationSchema { /// Custom header. Header { + #[serde(alias = "header_name")] name: String, #[serde(default)] prefix: Option, @@ -754,4 +780,217 @@ mod tests { assert!(auth.display_name.is_none()); assert!(auth.setup_url.is_none()); } + + // ── Category 1: Header field name alias ───────────────────────────── + + #[test] + fn test_header_location_with_name_field() { + let json = r#"{ + "http": { + "allowlist": [{ "host": "discord.com" }], + "credentials": { + "bot_token": { + "secret_name": "discord_bot_token", + "location": { "type": "header", "name": "Authorization", "prefix": "Bot " }, + "host_patterns": ["discord.com"] + } + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + let cred = http.credentials.get("bot_token").unwrap(); + match &cred.location { + CredentialLocationSchema::Header { name, prefix } => { + assert_eq!(name, "Authorization"); + assert_eq!(prefix, &Some("Bot ".to_string())); + } + _ => panic!("Expected Header location"), + } + } + + #[test] + fn test_header_location_with_header_name_alias() { + // Uses "header_name" instead of "name" — should parse via serde alias + let json = r#"{ + "http": { + "allowlist": [{ "host": "discord.com" }], + "credentials": { + "bot_token": { + "secret_name": "discord_bot_token", + "location": { "type": "header", "header_name": "Authorization", "prefix": "Bot " }, + "host_patterns": ["discord.com"] + } + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + let cred = http.credentials.get("bot_token").unwrap(); + match &cred.location { + CredentialLocationSchema::Header { name, prefix } => { + assert_eq!(name, "Authorization"); + assert_eq!(prefix, &Some("Bot ".to_string())); + } + _ => panic!("Expected Header location"), + } + } + + #[test] + fn test_discord_capabilities_file_parses() { + // Full Discord capabilities JSON — tests end-to-end parsing + let json = r#"{ + "type": "channel", + "name": "discord", + "description": "Discord channel", + "setup": { + "required_secrets": [ + { + "name": "discord_bot_token", + "prompt": "Enter your Discord Bot Token", + "optional": false + }, + { + "name": "discord_public_key", + "prompt": "Enter your Discord Public Key", + "optional": false + } + ] + }, + "capabilities": { + "http": { + "allowlist": [{ "host": "discord.com", "path_prefix": "/api/v10" }], + "credentials": { + "discord_bot_token": { + "secret_name": "discord_bot_token", + "location": { "type": "header", "name": "Authorization", "prefix": "Bot " }, + "host_patterns": ["discord.com"] + } + } + } + }, + "config": { + "require_signature_verification": true + } + }"#; + + // This must not panic — parsing should succeed + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + assert!(http.credentials.contains_key("discord_bot_token")); + } + + #[test] + fn test_header_location_missing_name_fails() { + // Neither "name" nor "header_name" provided — should fail + let json = r#"{ + "http": { + "allowlist": [{ "host": "example.com" }], + "credentials": { + "api_key": { + "secret_name": "my_key", + "location": { "type": "header", "prefix": "Key " }, + "host_patterns": ["example.com"] + } + } + } + }"#; + + assert!( + CapabilitiesFile::from_json(json).is_err(), + "Header without name or header_name should fail deserialization" + ); + } + + // ── resolve_nested tests ────────────────────────────────────────── + + #[test] + fn test_resolve_nested_outer_takes_precedence() { + // Outer http should win over inner http + let json = r#"{ + "http": { + "allowlist": [{ "host": "outer.example.com" }] + }, + "capabilities": { + "http": { + "allowlist": [{ "host": "inner.example.com" }] + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + assert_eq!( + http.allowlist[0].host, "outer.example.com", + "Outer http should take precedence over inner" + ); + } + + #[test] + fn test_resolve_nested_doubly_nested() { + // capabilities.capabilities.http should resolve to top-level + let json = r#"{ + "capabilities": { + "capabilities": { + "http": { + "allowlist": [{ "host": "deep.example.com" }] + } + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + assert_eq!( + http.allowlist[0].host, "deep.example.com", + "Doubly-nested capabilities should be resolved" + ); + } + + #[test] + fn test_resolve_nested_all_fields_promoted() { + // Inner has secrets, workspace, and auth — all should be promoted + let json = r#"{ + "capabilities": { + "secrets": { + "allowed_names": ["my_secret"] + }, + "workspace": { + "allowed_prefixes": ["data/"] + }, + "auth": { + "secret_name": "my_auth_token" + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + assert!(caps.secrets.is_some(), "secrets should be promoted"); + assert!(caps.workspace.is_some(), "workspace should be promoted"); + assert!(caps.auth.is_some(), "auth should be promoted"); + + assert_eq!(caps.secrets.unwrap().allowed_names, vec!["my_secret"]); + assert_eq!(caps.workspace.unwrap().allowed_prefixes, vec!["data/"]); + assert_eq!(caps.auth.unwrap().secret_name, "my_auth_token"); + } + + #[test] + fn test_resolve_nested_empty_capabilities_noop() { + // Empty inner capabilities should not clobber outer http + let json = r#"{ + "http": { + "allowlist": [{ "host": "preserved.example.com" }] + }, + "capabilities": {} + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + assert_eq!( + http.allowlist[0].host, "preserved.example.com", + "Empty inner capabilities should not clobber outer http" + ); + } }