composure 0.2.4

Discord bots over HTTP Interactions, no gateway: slash commands, components, modals and JSX-like views, on Cloudflare Workers (WASM) or any server
Documentation
//! Discord request signature validation (ed25519 over `timestamp || body`).
//!
//! Uses `ed25519-dalek` by default. With the `web-crypto` feature on `wasm32-unknown-unknown`
//! (JS hosts with Ed25519 Web Crypto, such as Cloudflare Workers) it uses the host's
//! `crypto.subtle` instead, so dalek is left out of the bundle. Neither path copies the body into
//! a new wasm/heap buffer.

use hex::FromHexError;

#[cfg(not(all(feature = "web-crypto", target_arch = "wasm32", target_os = "unknown")))]
mod dalek;
#[cfg(not(all(feature = "web-crypto", target_arch = "wasm32", target_os = "unknown")))]
use dalek as backend;

#[cfg(all(feature = "web-crypto", target_arch = "wasm32", target_os = "unknown"))]
mod web_crypto;
#[cfg(all(feature = "web-crypto", target_arch = "wasm32", target_os = "unknown"))]
use web_crypto as backend;

/// Why a key or signature was rejected
#[derive(Debug)]
pub enum ValidateError {
    /// Public key or signature is not hex of the right length (32 / 64 bytes)
    HexError(FromHexError),
    /// Public key is not a valid ed25519 key (or the host can't import it)
    InvalidPublicKey,
    /// Signature does not match
    InvalidSignature,
}

/// The application's public key, decoded (and with `web-crypto`, imported into the host) once so
/// it can verify any number of requests. Adapters keep one for the life of the isolate.
#[derive(Clone)]
pub struct PublicKey(backend::Key);

impl PublicKey {
    /// Parses the hex public key from the Developer Portal
    pub async fn from_hex(public_key: &str) -> Result<Self, ValidateError> {
        let mut bytes = [0u8; 32];
        hex::decode_to_slice(public_key, &mut bytes).map_err(ValidateError::HexError)?;
        backend::Key::new(&bytes).await.map(Self)
    }

    /// Checks the `X-Signature-Ed25519` signature (hex) of `timestamp || body`
    pub async fn verify(
        &self,
        signature: &str,
        timestamp: &str,
        body: &[u8],
    ) -> Result<(), ValidateError> {
        let mut signature_bytes = [0u8; 64];
        hex::decode_to_slice(signature, &mut signature_bytes).map_err(ValidateError::HexError)?;
        self.0
            .verify(&signature_bytes, timestamp.as_bytes(), body)
            .await
    }
}

impl std::fmt::Debug for PublicKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("PublicKey")
    }
}

/// Validates a request using ed25519. Parses `public_key` on every call; keep a [`PublicKey`] to
/// verify many requests.
///
/// Async because Web Crypto is; with dalek it completes on first poll.
pub async fn validate_request(
    public_key: &str,
    signature: &str,
    timestamp: &str,
    body: &[u8],
) -> Result<(), ValidateError> {
    PublicKey::from_hex(public_key)
        .await?
        .verify(signature, timestamp, body)
        .await
}

#[cfg(test)]
mod tests {
    use futures::executor::block_on;

    use super::*;

    fn validate(
        public_key: &str,
        signature: &str,
        timestamp: &str,
        body: &[u8],
    ) -> Result<(), ValidateError> {
        block_on(validate_request(public_key, signature, timestamp, body))
    }

    #[test]
    pub fn validate_request_ok() {
        let public_key = "852aec10972ef6dd0431747902c779342cc411ad6d42c2de16ef4c87895c61ad";
        let sig = "c91641b5c3d12f9c819d9b5c568ef7d660e7f9abc2c312f296c562f6d7b028dac80c6c8e5c8a11f7a21ee28dbb8c6cf2762118bee45c00b2df78065b3b59f20c";
        let timestamp = "1682372142";
        let body = br#"{"app_permissions":"137411140374081","application_id":"1052322265397739523","channel":{"flags":0,"guild_id":"798662131062931547","id":"941169456686723122","last_message_id":"1100155827400229026","name":"bot-stuff","nsfw":false,"parent_id":"798662131678969866","permissions":"140737488355327","position":1,"rate_limit_per_user":0,"topic":null,"type":0},"channel_id":"941169456686723122","data":{"guild_id":"798662131062931547","id":"1052358444704862218","name":"ping","type":1},"entitlement_sku_ids":[],"entitlements":[],"guild_id":"798662131062931547","guild_locale":"en-US","id":"1100173248714518568","locale":"en-US","member":{"avatar":null,"communication_disabled_until":null,"deaf":false,"flags":0,"is_pending":false,"joined_at":"2021-01-12T21:18:10.481000+00:00","mute":false,"nick":null,"pending":false,"permissions":"140737488355327","premium_since":null,"roles":["943607715639484456"],"user":{"avatar":"fa82e15e24ee16c9fcbf8dd34d10b4cc","avatar_decoration":null,"discriminator":"9846","display_name":null,"global_name":null,"id":"282265607313817601","public_flags":0,"username":"BlueFrog"}},"token":"aW50ZXJhY3Rpb246MTEwMDE3MzI0ODcxNDUxODU2ODppVTFuSkNSbndrZ01Na3RCWk81MVhTWkdSbk8yTlBaM1U3Z3JlckR4YUZJMTZFTm9wc21nZnlaSnN4ZUZCTTd0Q0Jzc09ac3BHV1E1MGlBZGZnZzh0NDJmTElIcTB1M0FZQTJPS1BxcG1GTEtZUjNDWWFEamhEeTRPMWZnS0R4dQ","type":2,"version":1}"#;

        let res = validate(public_key, sig, timestamp, body);

        assert!(res.is_ok());
    }

    #[test]
    pub fn validate_request_err() {
        let public_key = "852aec10972ef6dd0431747902c779342cc411ad6d42c2de16ef4c87895c61ad";
        let sig = "c91641b5c3d12f9c819d9b5c568ef7d660e7f9abc2c312f296c562f6d7b028dac80c6c8e5c8a11f7a21ee28dbb8c6cf2762118bee45c00b2df78065b3b59f20c";
        let timestamp = "1682371237";
        let body = br#"{"app_permissions":"137411140374081","application_id":"1052322265397739523","channel":{"flags":0,"guild_id":"798662131062931547","id":"941169456686723122","last_message_id":"1100155827400229026","name":"bot-stuff","nsfw":false,"parent_id":"798662131678969866","permissions":"140737488355327","position":1,"rate_limit_per_user":0,"topic":null,"type":0},"channel_id":"941169456686723122","data":{"guild_id":"798662131062931547","id":"1052358444704862218","name":"ping","type":1},"entitlement_sku_ids":[],"entitlements":[],"guild_id":"798662131062931547","guild_locale":"en-US","id":"1100173248714518568","locale":"en-US","member":{"avatar":null,"communication_disabled_until":null,"deaf":false,"flags":0,"is_pending":false,"joined_at":"2021-01-12T21:18:10.481000+00:00","mute":false,"nick":null,"pending":false,"permissions":"140737488355327","premium_since":null,"roles":["943607715639484456"],"user":{"avatar":"fa82e15e24ee16c9fcbf8dd34d10b4cc","avatar_decoration":null,"discriminator":"9846","display_name":null,"global_name":null,"id":"282265607313817601","public_flags":0,"username":"BlueFrog"}},"token":"aW50ZXJhY3Rpb246MTEwMDE3MzI0ODcxNDUxODU2ODppVTFuSkNSbndrZ01Na3RCWk81MVhTWkdSbk8yTlBaM1U3Z3JlckR4YUZJMTZFTm9wc21nZnlaSnN4ZUZCTTd0Q0Jzc09ac3BHV1E1MGlBZGZnZzh0NDJmTElIcTB1M0FZQTJPS1BxcG1GTEtZUjNDWWFEamhEeTRPMWZnS0R4dQ","type":2,"version":1}"#;

        let res = validate(public_key, sig, timestamp, body);

        assert!(matches!(res, Err(ValidateError::InvalidSignature)));
    }

    const PUBLIC_KEY: &str = "852aec10972ef6dd0431747902c779342cc411ad6d42c2de16ef4c87895c61ad";
    const SIGNATURE: &str = "c91641b5c3d12f9c819d9b5c568ef7d660e7f9abc2c312f296c562f6d7b028dac80c6c8e5c8a11f7a21ee28dbb8c6cf2762118bee45c00b2df78065b3b59f20c";

    #[test]
    pub fn validate_request_malformed_hex() {
        let res = validate("not hex", SIGNATURE, "1682372142", b"{}");
        assert!(matches!(res, Err(ValidateError::HexError(_))));

        let res = validate(PUBLIC_KEY, "zz", "1682372142", b"{}");
        assert!(matches!(res, Err(ValidateError::HexError(_))));
    }

    #[test]
    pub fn validate_request_wrong_length_public_key() {
        let res = validate(&PUBLIC_KEY[..62], SIGNATURE, "1682372142", b"{}");
        assert!(matches!(res, Err(ValidateError::HexError(_))));
    }

    #[test]
    pub fn validate_request_wrong_length_signature() {
        let res = validate(PUBLIC_KEY, &SIGNATURE[..126], "1682372142", b"{}");
        assert!(matches!(res, Err(ValidateError::HexError(_))));
    }
}