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
+52
View File
@@ -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();