Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(signature-validation): add ed25519 signature validation to twilight-util #2205

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ version = "0.0.0"

[dev-dependencies]
anyhow = { default-features = false, features = ["std"], version = "1" }
ed25519-dalek = "2"
futures-util = { default-features = false, version = "0.3" }
tokio-stream = { default-features = false, version = "0.1" }
hex = "0.4"
http-body-util = "0.1"
hyper = { features = ["server"], version = "1" }
hyper-util = { features = ["http1", "client-legacy"], version = "0.1" }
Expand All @@ -27,6 +25,7 @@ twilight-http = { path = "../twilight-http" }
twilight-lavalink = { path = "../twilight-lavalink" }
twilight-model = { path = "../twilight-model" }
twilight-standby = { path = "../twilight-standby" }
twilight-util = { path = "../twilight-util", features = ["signature-validation"] }

[[example]]
name = "cache-optimization"
Expand Down
25 changes: 11 additions & 14 deletions examples/model-webhook-slash.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
use ed25519_dalek::{Verifier, VerifyingKey, PUBLIC_KEY_LENGTH};
use hex::FromHex;
use http_body_util::{BodyExt, Full};
use hyper::{
body::{Bytes, Incoming},
Expand All @@ -19,12 +17,10 @@ use twilight_model::{
},
http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType},
};
use twilight_util::signature_validation::{Key, Signature, TIMESTAMP_HEADER};

/// Public key given from Discord.
static PUB_KEY: Lazy<VerifyingKey> = Lazy::new(|| {
VerifyingKey::from_bytes(&<[u8; PUBLIC_KEY_LENGTH] as FromHex>::from_hex("PUBLIC_KEY").unwrap())
.unwrap()
});
static PUB_KEY: Lazy<Key> = Lazy::new(|| Key::from_hex("PUBLIC_KEY".as_bytes()).unwrap());

/// Main request handler which will handle checking the signature.
///
Expand Down Expand Up @@ -55,7 +51,7 @@ where
}

// Extract the timestamp header for use later to check the signature.
let timestamp = if let Some(ts) = req.headers().get("x-signature-timestamp") {
let timestamp = if let Some(ts) = req.headers().get(TIMESTAMP_HEADER) {
ts.to_owned()
} else {
return Ok(Response::builder()
Expand All @@ -66,10 +62,14 @@ where
// Extract the signature to check against.
let signature = if let Some(hex_sig) = req
.headers()
.get("x-signature-ed25519")
.and_then(|v| v.to_str().ok())
.get(twilight_util::signature_validation::SIGNATURE_HEADER)
{
hex_sig.parse().unwrap()
let Ok(sig) = Signature::from_slice(hex_sig.as_bytes()) else {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Full::default())?);
};
sig
} else {
return Ok(Response::builder()
.status(StatusCode::BAD_REQUEST)
Expand All @@ -82,10 +82,7 @@ where

// Check if the signature matches and else return a error response.
if PUB_KEY
.verify(
[timestamp.as_bytes(), &whole_body].concat().as_ref(),
&signature,
)
.verify(&signature, timestamp.as_bytes(), &whole_body)
.is_err()
{
return Ok(Response::builder()
Expand Down
9 changes: 8 additions & 1 deletion twilight-util/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,24 @@ version = "0.16.0-rc.1"
twilight-model = { default-features = false, optional = true, path = "../twilight-model", version = "0.16.0-rc.1" }
twilight-validate = { default-features = false, optional = true, path = "../twilight-validate", version = "0.16.0-rc.1" }

# Signature validation
ed25519-dalek = { version = "2.1.0", optional = true, default-features = false, features = ["std"] }
hex = { version = "0.4.3", optional = true, default-features = false, features = ["std"] }
serde_json = { optional = true, version = "1.0.96", default-features = false, features = ["std"] }

[dev-dependencies]
chrono = { default-features = false, features = ["std"], version = "0.4" }
static_assertions = { default-features = false, version = "1" }
time = { default-features = false, features = ["formatting"], version = "0.3" }

[features]
default = ["full"]
builder = ["dep:twilight-model", "dep:twilight-validate"]
link = ["dep:twilight-model"]
permission-calculator = ["dep:twilight-model"]
snowflake = ["dep:twilight-model"]
full = ["builder", "link", "permission-calculator", "snowflake"]
signature-validation = ["dep:ed25519-dalek", "dep:hex"]
full = ["builder", "link", "permission-calculator", "snowflake", "signature-validation"]

[package.metadata.docs.rs]
all-features = true
Expand Down
6 changes: 6 additions & 0 deletions twilight-util/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ a guild or channel.
Allows the use of the `Snowflake` trait, which provides methods for the extraction of
structured information from [Discord snowflakes].

### `signature-validation`

Provides utilities for doing [HTTP Interaction] [signature validation].

[`twilight-rs`]: https://github.com/twilight-rs/twilight
[codecov badge]: https://img.shields.io/codecov/c/gh/twilight-rs/twilight?logo=codecov&style=for-the-badge&token=E9ERLJL0L2
[codecov link]: https://app.codecov.io/gh/twilight-rs/twilight/
Expand All @@ -37,3 +41,5 @@ structured information from [Discord snowflakes].
[license link]: https://github.com/twilight-rs/twilight/blob/main/LICENSE.md
[rust badge]: https://img.shields.io/badge/rust-1.67+-93450a.svg?style=for-the-badge&logo=rust
[Discord snowflakes]: https://discord.com/developers/docs/reference#snowflakes
[HTTP Interaction]: https://discord.com/developers/docs/interactions/receiving-and-responding#receiving-an-interaction
[signature validation]: https://discord.com/developers/docs/interactions/receiving-and-responding#security-and-authorization
3 changes: 3 additions & 0 deletions twilight-util/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,6 @@ pub mod permission_calculator;

#[cfg(feature = "snowflake")]
pub mod snowflake;

#[cfg(feature = "signature-validation")]
pub mod signature_validation;
219 changes: 219 additions & 0 deletions twilight-util/src/signature_validation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
//! Provides signature validation as is required for bots which work by giving Discord
//! an HTTPS endpoint to send Interactions to.
//!
//! See <https://discord.com/developers/docs/interactions/overview#preparing-for-interactions>
//! for more details.

use ed25519_dalek::{Signature as DalekSignature, VerifyingKey as DalekVerifyingKey};

/// The name of the HTTP header Discord wants us to read for the signature.
pub const SIGNATURE_HEADER: &str = "x-signature-ed25519";
/// The name of the HTTP header Discord wants us to read for the signature timestamp.
pub const TIMESTAMP_HEADER: &str = "x-signature-timestamp";

/// The key you are meant to get from the Discord Developer Portal,
/// on your Application. It is currently listed on the General Information page,
/// labeled "Public Key", at the time of this writing (January 19th, 2024).
pub struct Key {
inner: DalekVerifyingKey,
}

impl Key {
/// This function consumes the hexadecimal string which Discord
/// provides public keys in. Use `.as_bytes()` on a `&str`, or otherwise
/// obtain a byte-string of that text, to use with this function.
///
/// # Errors
/// This will fail if given invalid hexadecimal, or if the public key fails to
/// meet mathematical requirements.
pub fn from_hex(pub_key: &[u8]) -> Result<Self, KeyError> {
let mut key = [0; 32];
hex::decode_to_slice(pub_key, &mut key).map_err(|e| KeyError {
kind: KeyErrorKind::Hex,
source: Some(e.into()),
})?;
DalekVerifyingKey::from_bytes(&key)
.map(|inner| Key { inner })
.map_err(|err| KeyError {
kind: KeyErrorKind::MalformedKey,
source: Some(err.into()),
})
}
/// Verify a signature for a given message body, timestamp, and signing key.
///
/// (This method is a duplicate of [`check_signature`].)
///
/// # Errors
/// This will fail if the request being verified was given the wrong key.
pub fn verify(
&self,
signature: &Signature,
timestamp: &[u8],
body: &[u8],
) -> Result<(), SignatureValidationFailure> {
check_signature(signature, timestamp, body, self)
}
}

/// Signature extracted from the header of the incoming request.
///
/// The specific header can be found in [`SIGNATURE_HEADER`].
pub struct Signature {
inner: DalekSignature,
}

impl Signature {
/// Create a signature from a slice.
///
/// # Errors
/// This will fail if the hex slice is invalid.
pub fn from_slice(signature: &[u8]) -> Result<Signature, SignatureValidationFailure> {
let mut sig_buf = [0; 64];
hex::decode_to_slice(signature, &mut sig_buf).map_err(|e| SignatureValidationFailure {
kind: SignatureValidationFailureKind::Hex,
source: Some(e.into()),
})?;
let sig = DalekSignature::from_bytes(&sig_buf);
Ok(Signature { inner: sig })
}
}

/// Validates that a signature is valid for a given message body, timestamp, and signing key.
///
/// # Errors
/// This will fail if the request being validated has the wrong key.
pub fn check_signature(
signature: &Signature,
timestamp: &[u8],
body: &[u8],
key: &Key,
) -> Result<(), SignatureValidationFailure> {
let mut buf = Vec::with_capacity(timestamp.len() + body.len());
buf.extend_from_slice(timestamp);
buf.extend_from_slice(body);
match key.inner.verify_strict(&buf, &signature.inner) {
Ok(()) => Ok(()),
Err(e) => Err(SignatureValidationFailure {
kind: SignatureValidationFailureKind::InvalidSignature,
source: Some(e.into()),
}),
}
}

/// Signature validation failed. If you successfully gave your program
/// the public key provided by Discord, this is almost definitely because
/// you received an invalid request.
#[derive(Debug)]
pub struct SignatureValidationFailure {
kind: SignatureValidationFailureKind,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}

/// The kind of [`SignatureValidationFailure`] that occurred.
#[derive(Debug)]
pub enum SignatureValidationFailureKind {
/// The request signature was invalid hexadecimal.
Hex, //(KeyParseError),
/// Request had invalid signature for the given public key.
InvalidSignature, //(SigError),
}

impl SignatureValidationFailure {
/// Immutable reference to the type of error that occurred.
#[must_use = "retrieving the type has no effect if left unused"]
pub const fn kind(&self) -> &SignatureValidationFailureKind {
&self.kind
}

/// Consume the error, returning the source error if there is any.
#[must_use = "consuming the error and retrieving the source has no effect if left unused"]
pub fn into_source(self) -> Option<Box<dyn std::error::Error + Send + Sync>> {
self.source
}

/// Consume the error, returning the owned error type and the source error.
#[must_use = "consuming the error into its parts has no effect if left unused"]
pub fn into_parts(
self,
) -> (
SignatureValidationFailureKind,
Option<Box<dyn std::error::Error + Send + Sync>>,
) {
(self.kind, self.source)
}
}

impl std::fmt::Display for SignatureValidationFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
SignatureValidationFailureKind::Hex => f.write_str("signature hex is invalid"),
SignatureValidationFailureKind::InvalidSignature => f.write_str("signature is invalid"),
}
}
}

impl std::error::Error for SignatureValidationFailure {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|source| &**source as &(dyn std::error::Error + 'static))
}
}

/// Error occurring when the key cannot be parsed.
#[derive(Debug)]
pub struct KeyError {
kind: KeyErrorKind,
source: Option<Box<dyn std::error::Error + Send + Sync>>,
}

/// Type of [`KeyError`] that occurred.
#[derive(Debug)]
pub enum KeyErrorKind {
/// The public key was invalid hexadecimal.
Hex,
/// The public key was malformed.
MalformedKey,
}

impl KeyError {
/// Immutable reference to the type of error that occurred.
#[must_use = "retrieving the type has no effect if left unused"]
pub const fn kind(&self) -> &KeyErrorKind {
&self.kind
}

/// Consume the error, returning the source error if there is any.
#[must_use = "consuming the error and retrieving the source has no effect if left unused"]
pub fn into_source(self) -> Option<Box<dyn std::error::Error + Send + Sync>> {
self.source
}

/// Consume the error, returning the owned error type and the source error.
#[must_use = "consuming the error into its parts has no effect if left unused"]
pub fn into_parts(
self,
) -> (
KeyErrorKind,
Option<Box<dyn std::error::Error + Send + Sync>>,
) {
(self.kind, self.source)
}
}

impl std::fmt::Display for KeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.kind {
KeyErrorKind::Hex => f.write_str("could not parse hex string"),
KeyErrorKind::MalformedKey => f.write_str("key was malformed"),
}
}
}

impl std::error::Error for KeyError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.source
.as_ref()
.map(|source| &**source as &(dyn std::error::Error + 'static))
}
}