fix: Discord Ed25519 signature verification and capabilities header alias (#148) (#372)

* test: add failing tests for Discord signature validation and capabilities alias (Red phase)

TDD Red phase for #148. Adds 19 tests across 4 categories:
- Category 1: CredentialLocationSchema header_name alias (2 failing)
- Category 2: Ed25519 signature verification (3 failing)
- Category 3: Router signature key management (2 failing)
- Category 5: Discord capabilities public_key setup (1 failing)

All 8 failures are expected — stubs return false/None by design.
Implementation will follow in Green phase.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: add Discord Ed25519 signature verification and capabilities alias (#148)

Implement the Green phase for Discord channel security fixes:

- Add real Ed25519 signature verification in signature.rs using ed25519-dalek
- Add #[serde(alias = "header_name")] to CredentialLocationSchema::Header
  for backward compatibility with external JSON files
- Add signature_keys storage to WasmChannelRouter (register/get/unregister)
- Add discord_public_key to discord.capabilities.json setup.required_secrets
- Add nested capabilities resolution to CapabilitiesFile for channel-level
  JSON compatibility

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* style: address PR #372 review comments

- Fix invalid hex character in test fake_pub_key (router.rs)
- Simplify signature parsing with from_slice/try_from (signature.rs)
- Use idiomatic Option::or for nested capability merging (capabilities_schema.rs)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: enforce signature verification, staleness check, key validation, recursive resolve

Address PR #372 review feedback:

- Wire verify_discord_signature() into webhook_handler with Ed25519
  signature + timestamp staleness check (5s window via now_secs param)
- Validate Ed25519 keys in register_signature_key() (hex decode +
  VerifyingKey::try_from) before storing, return Result<(), String>
- Recursively resolve nested capabilities in resolve_nested()
- Add 25 new tests: 8 staleness, 6 key validation, 7 webhook
  integration (tower::oneshot), 4 resolve_nested edge cases
- Fix pre-existing clippy warning in signal.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wire register_signature_key() into all channel loading paths

The Ed25519 signature key registration was implemented and tested but
never called from production code. All three channel loading paths
(setup_wasm_channels, activate_wasm_channel, refresh_active_channel)
now read the public key from the secrets store and register it with
the webhook router, enabling Discord signature verification.

Adds `signature_key_secret_name` field to WebhookSchema so channels
can declare which secret contains their Ed25519 public key.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
KemonoNeco
2026-02-27 07:01:54 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a24fd3e8a3
commit a7c0be7f1b
11 changed files with 1415 additions and 2 deletions
Generated
+110
View File
@@ -492,6 +492,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bincode" name = "bincode"
version = "1.3.3" version = "1.3.3"
@@ -949,6 +955,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "const-random" name = "const-random"
version = "0.1.18" version = "0.1.18"
@@ -1353,6 +1365,33 @@ dependencies = [
"cipher", "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]] [[package]]
name = "darling" name = "darling"
version = "0.21.3" version = "0.21.3"
@@ -1438,6 +1477,16 @@ dependencies = [
"uuid", "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]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.6" version = "0.5.6"
@@ -1607,6 +1656,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" 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]] [[package]]
name = "ego-tree" name = "ego-tree"
version = "0.10.0" version = "0.10.0"
@@ -1773,6 +1846,12 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.27" version = "0.2.27"
@@ -2719,9 +2798,11 @@ dependencies = [
"deadpool-postgres", "deadpool-postgres",
"dirs 6.0.0", "dirs 6.0.0",
"dotenvy", "dotenvy",
"ed25519-dalek",
"flate2", "flate2",
"fs4", "fs4",
"futures", "futures",
"hex",
"hkdf", "hkdf",
"html-to-markdown-rs", "html-to-markdown-rs",
"http-body-util", "http-body-util",
@@ -3814,6 +3895,16 @@ dependencies = [
"futures-io", "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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -5104,6 +5195,15 @@ dependencies = [
"libc", "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]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.8"
@@ -5157,6 +5257,16 @@ dependencies = [
"windows-sys 0.60.2", "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]] [[package]]
name = "sptr" name = "sptr"
version = "0.3.2" version = "0.3.2"
+2
View File
@@ -154,6 +154,8 @@ lru = "0.16.3"
# HTML to Markdown conversion (feature gated) # HTML to Markdown conversion (feature gated)
html-to-markdown-rs = { version = "2.3", optional = true } html-to-markdown-rs = { version = "2.3", optional = true }
readabilityrs = { version = "0.1.2", optional = true } readabilityrs = { version = "0.1.2", optional = true }
ed25519-dalek = { version = "2.2.0", features = ["std"] }
hex = "0.4.3"
# macOS keychain # macOS keychain
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
@@ -8,6 +8,11 @@
"name": "discord_bot_token", "name": "discord_bot_token",
"prompt": "Enter your Discord Bot Token (from Developer Portal)", "prompt": "Enter your Discord Bot Token (from Developer Portal)",
"optional": false "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": { "emit_rate_limit": {
"messages_per_minute": 100, "messages_per_minute": 100,
"messages_per_hour": 5000 "messages_per_hour": 5000
},
"webhook": {
"signature_key_secret_name": "discord_public_key"
} }
} }
}, },
+19
View File
@@ -248,6 +248,13 @@ impl LoadedChannel {
.and_then(|f| f.webhook_secret_header()) .and_then(|f| f.webhook_secret_header())
} }
/// Get the signature verification key secret name from capabilities.
pub fn signature_key_secret_name(&self) -> Option<String> {
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. /// Get the webhook secret name from capabilities.
pub fn webhook_secret_name(&self) -> String { pub fn webhook_secret_name(&self) -> String {
self.capabilities_file self.capabilities_file
@@ -416,6 +423,18 @@ mod tests {
assert!(channels.contains_key("channel")); 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<crate::channels::wasm::schema::ChannelCapabilitiesFile> = 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] #[tokio::test]
async fn test_loader_invalid_name() { async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing(); let config = WasmChannelRuntimeConfig::for_testing();
+1
View File
@@ -86,6 +86,7 @@ mod loader;
mod router; mod router;
mod runtime; mod runtime;
mod schema; mod schema;
pub(crate) mod signature;
mod wrapper; mod wrapper;
// Core types // Core types
+518
View File
@@ -42,6 +42,8 @@ pub struct WasmChannelRouter {
secrets: RwLock<HashMap<String, String>>, secrets: RwLock<HashMap<String, String>>,
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token"). /// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
secret_headers: RwLock<HashMap<String, String>>, secret_headers: RwLock<HashMap<String, String>>,
/// Ed25519 public keys for signature verification by channel name (hex-encoded).
signature_keys: RwLock<HashMap<String, String>>,
} }
impl WasmChannelRouter { impl WasmChannelRouter {
@@ -52,6 +54,7 @@ impl WasmChannelRouter {
path_to_channel: RwLock::new(HashMap::new()), path_to_channel: RwLock::new(HashMap::new()),
secrets: RwLock::new(HashMap::new()), secrets: RwLock::new(HashMap::new()),
secret_headers: 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.channels.write().await.remove(channel_name);
self.secrets.write().await.remove(channel_name); self.secrets.write().await.remove(channel_name);
self.secret_headers.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 // Remove all paths for this channel
self.path_to_channel self.path_to_channel
@@ -174,6 +178,36 @@ impl WasmChannelRouter {
pub async fn list_paths(&self) -> Vec<String> { pub async fn list_paths(&self) -> Vec<String> {
self.path_to_channel.read().await.keys().cloned().collect() 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<String> {
self.signature_keys.read().await.get(channel_name).cloned()
}
} }
impl Default for WasmChannelRouter { 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 // Convert headers to HashMap
let headers_map: HashMap<String, String> = headers let headers_map: HashMap<String, String> = headers
.iter() .iter()
@@ -644,4 +729,437 @@ mod tests {
.await; .await;
assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); 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<WasmChannelRouter>, AxumRouter) {
let wasm_router = Arc::new(WasmChannelRouter::new());
let channel = create_test_channel("discord");
let endpoints = vec![RegisteredEndpoint {
channel_name: "discord".to_string(),
path: "/webhook/discord".to_string(),
methods: vec!["POST".to_string()],
require_secret: false,
}];
wasm_router.register(channel, endpoints, None, None).await;
let app = create_wasm_channel_router(wasm_router.clone(), None);
(wasm_router, app)
}
/// 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", &timestamp)
.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", &timestamp)
.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"
);
}
} }
+103
View File
@@ -111,6 +111,18 @@ impl ChannelCapabilitiesFile {
.and_then(|w| w.secret_header.as_deref()) .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. /// Get the webhook secret name for this channel.
/// ///
/// Returns the configured secret name or defaults to "{channel_name}_webhook_secret". /// Returns the configured secret name or defaults to "{channel_name}_webhook_secret".
@@ -230,6 +242,11 @@ pub struct WebhookSchema {
/// Default: "{channel_name}_webhook_secret" /// Default: "{channel_name}_webhook_secret"
#[serde(default)] #[serde(default)]
pub secret_name: Option<String>, pub secret_name: Option<String>,
/// 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<String>,
} }
/// Setup configuration schema. /// Setup configuration schema.
@@ -585,4 +602,90 @@ mod tests {
64 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"
);
}
} }
+341
View File
@@ -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: <https://discord.com/developers/docs/interactions/overview#validating-security-request-headers>
/// 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"
);
}
}
+52
View File
@@ -1796,6 +1796,7 @@ impl ExtensionManager {
let channel_name = loaded.name().to_string(); let channel_name = loaded.name().to_string();
let webhook_secret_name = loaded.webhook_secret_name(); let webhook_secret_name = loaded.webhook_secret_name();
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); 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 // Get webhook secret from secrets store
let webhook_secret = self let webhook_secret = self
@@ -1861,6 +1862,26 @@ impl ExtensionManager {
) )
.await; .await;
tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router"); 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 // Inject credentials
@@ -1996,6 +2017,37 @@ impl ExtensionManager {
existing_channel.update_config(config_updates).await; 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 // Refresh tunnel_url in case it wasn't set at startup
if let Some(ref tunnel_url) = self.tunnel_url { if let Some(ref tunnel_url) = self.tunnel_url {
let mut config_updates = std::collections::HashMap::new(); let mut config_updates = std::collections::HashMap::new();
+20
View File
@@ -896,6 +896,7 @@ async fn setup_wasm_channels(
tracing::info!("Loaded WASM channel: {}", channel_name); tracing::info!("Loaded WASM channel: {}", channel_name);
let secret_name = loaded.webhook_secret_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 { let webhook_secret = if let Some(secrets) = secrets_store {
secrets secrets
@@ -969,6 +970,25 @@ async fn setup_wasm_channels(
secret_header, secret_header,
) )
.await; .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 { if let Some(secrets) = secrets_store {
match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await { match inject_channel_credentials(&channel_arc, secrets.as_ref(), &channel_name).await {
Ok(count) => { Ok(count) => {
+241 -2
View File
@@ -61,17 +61,42 @@ pub struct CapabilitiesFile {
/// Used by `ironclaw config` to guide users through auth setup. /// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)] #[serde(default)]
pub auth: Option<AuthCapabilitySchema>, pub auth: Option<AuthCapabilitySchema>,
/// 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<Box<CapabilitiesFile>>,
} }
impl CapabilitiesFile { impl CapabilitiesFile {
/// Parse from JSON string. /// Parse from JSON string.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> { pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json) serde_json::from_str::<Self>(json).map(Self::resolve_nested)
} }
/// Parse from JSON bytes. /// Parse from JSON bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> { pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes) serde_json::from_slice::<Self>(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. /// Convert to runtime Capabilities.
@@ -234,6 +259,7 @@ pub enum CredentialLocationSchema {
/// Custom header. /// Custom header.
Header { Header {
#[serde(alias = "header_name")]
name: String, name: String,
#[serde(default)] #[serde(default)]
prefix: Option<String>, prefix: Option<String>,
@@ -754,4 +780,217 @@ mod tests {
assert!(auth.display_name.is_none()); assert!(auth.display_name.is_none());
assert!(auth.setup_url.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"
);
}
} }