claudy 0.2.2

Modern multi-provider launcher for Claude CLI
use ed25519_dalek::{Signature, VerifyingKey};
use serde::Deserialize;

/// Verify an incoming Discord webhook request signature.
///
/// Discord signs requests using Ed25519. The signature covers the concatenation
/// of the `X-Discord-Signature-Timestamp` header value and the raw request body.
///
/// Returns `true` when the signature is valid, `false` otherwise.
pub fn verify_discord_signature(
    public_key: &[u8],
    body: &[u8],
    signature: &[u8],
    timestamp: &[u8],
) -> bool {
    let public_key_array: &[u8; 32] = match <&[u8; 32]>::try_from(public_key) {
        Ok(arr) => arr,
        Err(_) => return false,
    };

    let verifying_key = match VerifyingKey::from_bytes(public_key_array) {
        Ok(k) => k,
        Err(_) => return false,
    };

    let sig = match Signature::from_slice(signature) {
        Ok(s) => s,
        Err(_) => return false,
    };

    let mut message = Vec::with_capacity(timestamp.len() + body.len());
    message.extend_from_slice(timestamp);
    message.extend_from_slice(body);

    verifying_key.verify_strict(&message, &sig).is_ok()
}

/// Interaction types sent by Discord.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscordInteractionType {
    Ping = 1,
    ApplicationCommand = 2,
    MessageComponent = 3,
}

impl TryFrom<u8> for DiscordInteractionType {
    type Error = u8;
    fn try_from(value: u8) -> Result<Self, Self::Error> {
        match value {
            1 => Ok(Self::Ping),
            2 => Ok(Self::ApplicationCommand),
            3 => Ok(Self::MessageComponent),
            other => Err(other),
        }
    }
}

fn deserialize_interaction_type<'de, D>(deserializer: D) -> Result<DiscordInteractionType, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let raw = u8::deserialize(deserializer)?;
    DiscordInteractionType::try_from(raw)
        .map_err(|v| serde::de::Error::custom(format!("unknown Discord interaction type: {v}")))
}

/// Incoming Discord interaction payload (partial -- only fields we need).
#[derive(Debug, Deserialize)]
pub struct DiscordInteraction {
    #[serde(rename = "type", deserialize_with = "deserialize_interaction_type")]
    pub interaction_type: DiscordInteractionType,
    pub id: String,
    pub token: String,
    pub channel_id: Option<String>,
    pub user_id: Option<String>,
    pub data: Option<DiscordInteractionData>,
    /// The message object for component interactions (contains the original message ID).
    #[serde(default)]
    pub message: Option<DiscordMessageRef>,
}

/// Minimal message reference from a Discord interaction (just the ID).
#[derive(Debug, Deserialize)]
pub struct DiscordMessageRef {
    pub id: String,
}

/// The `data` field of an interaction (varies by type).
#[derive(Debug, Deserialize)]
pub struct DiscordInteractionData {
    #[serde(default)]
    pub name: Option<String>,
    pub options: Option<Vec<DiscordOption>>,
    pub custom_id: Option<String>,
    pub component_type: Option<u8>,
}

impl DiscordInteraction {
    /// Construct from a Gateway INTERACTION_CREATE event payload.
    pub fn from_gateway_event(data: &serde_json::Value) -> Option<Self> {
        let interaction_type = match data.get("type").and_then(|t| t.as_u64()) {
            Some(1) => DiscordInteractionType::Ping,
            Some(2) => DiscordInteractionType::ApplicationCommand,
            Some(3) => DiscordInteractionType::MessageComponent,
            _ => return None,
        };
        let id = data.get("id").and_then(|v| v.as_str())?.to_string();
        let token = data.get("token").and_then(|v| v.as_str())?.to_string();
        let channel_id = data
            .get("channel_id")
            .and_then(|v| v.as_str())
            .map(String::from);
        let user_id = data
            .get("member")
            .and_then(|m| m.get("user"))
            .or_else(|| data.get("user"))
            .and_then(|u| u.get("id"))
            .and_then(|v| v.as_str())
            .map(String::from);

        let data_field = data.get("data");
        let discord_data = data_field.map(|d| DiscordInteractionData {
            name: d.get("name").and_then(|v| v.as_str()).map(String::from),
            options: d
                .get("options")
                .and_then(|o| serde_json::from_value(o.clone()).ok()),
            custom_id: d
                .get("custom_id")
                .and_then(|v| v.as_str())
                .map(String::from),
            component_type: d
                .get("component_type")
                .and_then(|v| v.as_u64())
                .map(|v| v as u8),
        });

        let message = data
            .get("message")
            .and_then(|m| m.get("id"))
            .and_then(|v| v.as_str())
            .map(|id| DiscordMessageRef { id: id.to_string() });

        Some(DiscordInteraction {
            interaction_type,
            id,
            token,
            channel_id,
            user_id,
            data: discord_data,
            message,
        })
    }
}

/// A single slash-command option.
#[derive(Debug, Deserialize)]
pub struct DiscordOption {
    pub name: String,
    pub value: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use ed25519_dalek::{Signer, SigningKey};
    use rand::Rng;

    #[test]
    fn verify_valid_signature() {
        let mut seed = [0u8; 32];
        rand::rng().fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let verifying_key = signing_key.verifying_key();
        let public_key_bytes = verifying_key.to_bytes();

        let timestamp = b"1234567890";
        let body = br#"{"type":1}"#;

        let mut message = Vec::new();
        message.extend_from_slice(timestamp);
        message.extend_from_slice(body);

        let signature = signing_key.sign(&message);
        let sig_bytes = signature.to_bytes();

        assert!(verify_discord_signature(
            &public_key_bytes,
            body,
            &sig_bytes,
            timestamp,
        ));
    }

    #[test]
    fn reject_tampered_body() {
        let mut seed = [0u8; 32];
        rand::rng().fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let verifying_key = signing_key.verifying_key();
        let public_key_bytes = verifying_key.to_bytes();

        let timestamp = b"1234567890";
        let body = br#"{"type":1}"#;

        let mut message = Vec::new();
        message.extend_from_slice(timestamp);
        message.extend_from_slice(body);

        let signature = signing_key.sign(&message);
        let sig_bytes = signature.to_bytes();

        assert!(!verify_discord_signature(
            &public_key_bytes,
            br#"{"type":2}"#,
            &sig_bytes,
            timestamp,
        ));
    }

    #[test]
    fn reject_invalid_public_key() {
        let mut seed = [0u8; 32];
        rand::rng().fill_bytes(&mut seed);
        let signing_key = SigningKey::from_bytes(&seed);
        let _verifying_key = signing_key.verifying_key();

        let timestamp = b"1234567890";
        let body = br#"{"type":1}"#;

        let mut message = Vec::new();
        message.extend_from_slice(timestamp);
        message.extend_from_slice(body);

        let signature = signing_key.sign(&message);
        let sig_bytes = signature.to_bytes();

        // Wrong public key
        let mut wrong_seed = [0u8; 32];
        rand::rng().fill_bytes(&mut wrong_seed);
        let wrong_key = SigningKey::from_bytes(&wrong_seed);
        let wrong_bytes = wrong_key.verifying_key().to_bytes();

        assert!(!verify_discord_signature(
            &wrong_bytes,
            body,
            &sig_bytes,
            timestamp,
        ));
    }

    #[test]
    fn deserialize_ping() {
        let json =
            r#"{"type":1,"id":"9","token":"tok","channel_id":null,"user_id":null,"data":null}"#;
        let interaction: DiscordInteraction = serde_json::from_str(json).expect("parse");
        assert_eq!(interaction.interaction_type, DiscordInteractionType::Ping);
    }

    #[test]
    fn deserialize_application_command() {
        let json = r#"{
            "type":2,
            "id":"99",
            "token":"tok",
            "channel_id":"ch1",
            "user_id":"u1",
            "data":{"name":"ask","options":[{"name":"prompt","value":"hello"}]}
        }"#;
        let interaction: DiscordInteraction = serde_json::from_str(json).expect("parse");
        assert_eq!(
            interaction.interaction_type,
            DiscordInteractionType::ApplicationCommand
        );
        let data = interaction.data.expect("data present");
        assert_eq!(data.name.as_deref(), Some("ask"));
        let opt = data.options.expect("options present");
        assert_eq!(opt[0].name, "prompt");
        assert_eq!(opt[0].value, Some("hello".into()));
    }
}