mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* 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:
co-authored by
Claude Opus 4.6
parent
a24fd3e8a3
commit
a7c0be7f1b
Generated
+110
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<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.
|
||||
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<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]
|
||||
async fn test_loader_invalid_name() {
|
||||
let config = WasmChannelRuntimeConfig::for_testing();
|
||||
|
||||
@@ -86,6 +86,7 @@ mod loader;
|
||||
mod router;
|
||||
mod runtime;
|
||||
mod schema;
|
||||
pub(crate) mod signature;
|
||||
mod wrapper;
|
||||
|
||||
// Core types
|
||||
|
||||
@@ -42,6 +42,8 @@ pub struct WasmChannelRouter {
|
||||
secrets: RwLock<HashMap<String, String>>,
|
||||
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
|
||||
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 {
|
||||
@@ -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<String> {
|
||||
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 {
|
||||
@@ -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<String, String> = 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<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", ×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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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.
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
+20
@@ -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) => {
|
||||
|
||||
@@ -61,17 +61,42 @@ pub struct CapabilitiesFile {
|
||||
/// Used by `ironclaw config` to guide users through auth setup.
|
||||
#[serde(default)]
|
||||
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 {
|
||||
/// Parse from JSON string.
|
||||
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.
|
||||
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.
|
||||
@@ -234,6 +259,7 @@ pub enum CredentialLocationSchema {
|
||||
|
||||
/// Custom header.
|
||||
Header {
|
||||
#[serde(alias = "header_name")]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
prefix: Option<String>,
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user