Skip to content

Commit 44030ce

Browse files
committed
impl verification on payloads
1 parent a57d0ce commit 44030ce

File tree

3 files changed

+99
-2
lines changed

3 files changed

+99
-2
lines changed

Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ serde_repr = "0.1.6"
2626
reqwest = { version = "0.11.0", optional = true }
2727
surf = { version = "2.1.0", optional = true }
2828
http-types = { version = "2.10.0", optional = true, features = ["hyperium_http"] }
29+
sha2 = { version = "0.9.3", optional = true }
30+
crypto_hmac = { package = "hmac", version = "0.10.1", optional = true }
2931

3032
[features]
3133
default = []
@@ -57,7 +59,9 @@ pubsub = ["serde_json"]
5759

5860
eventsub = ["serde_json"]
5961

60-
all = ["tmi", "helix", "surf_client", "reqwest_client", "client", "pubsub", "eventsub"]
62+
hmac = ["crypto_hmac","sha2"]
63+
64+
all = ["tmi", "helix", "surf_client", "reqwest_client", "client", "pubsub", "eventsub", "hmac"]
6165

6266
[dev-dependencies]
6367
tokio = { version = "1.2.0", features = ["rt-multi-thread", "macros"] }

src/eventsub/mod.rs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,66 @@ impl Payload {
155155
pub fn parse_http(request: &http::Request<Vec<u8>>) -> Result<Payload, PayloadParseError> {
156156
Payload::parse(std::str::from_utf8(request.body())?)
157157
}
158+
159+
/// Verify that this payload is authentic using `HMAC-SHA256`.
160+
///
161+
/// HMAC key is `secret`, HMAC message is a concatenation of `Twitch-Eventsub-Message-Id` header, `Twitch-Eventsub-Message-Timestamp` header and the request body.
162+
/// HMAC signature is `Twitch-Eventsub-Message-Signature` header
163+
#[cfg(feature = "hmac")]
164+
#[cfg_attr(nightly, doc(cfg(feature = "hmac")))]
165+
pub fn verify_payload(request: &http::Request<Vec<u8>>, secret: &[u8]) -> bool {
166+
use crypto_hmac::{Hmac, Mac, NewMac};
167+
168+
fn message_and_signature(request: &http::Request<Vec<u8>>) -> Option<(Vec<u8>, Vec<u8>)> {
169+
static SHA_HEADER: &str = "sha256=";
170+
171+
let id = request
172+
.headers()
173+
.get("Twitch-Eventsub-Message-Id")?
174+
.as_bytes();
175+
let timestamp = request
176+
.headers()
177+
.get("Twitch-Eventsub-Message-Timestamp")?
178+
.as_bytes();
179+
let body = request.body();
180+
181+
let mut message = Vec::with_capacity(id.len() + timestamp.len() + body.len());
182+
message.extend_from_slice(&id);
183+
message.extend_from_slice(&timestamp);
184+
message.extend_from_slice(&body);
185+
186+
let signature = request
187+
.headers()
188+
.get("Twitch-Eventsub-Message-Signature")?
189+
.to_str()
190+
.ok()?;
191+
if !signature.starts_with(&SHA_HEADER) {
192+
return None;
193+
}
194+
let signature = signature.split_at(SHA_HEADER.len()).1;
195+
if signature.len() % 2 == 0 {
196+
// Convert signature to [u8] from hex digits
197+
// Hex decode inspired by https://stackoverflow.com/a/52992629
198+
let signature = ((0..signature.len())
199+
.step_by(2)
200+
.map(|i| u8::from_str_radix(&signature[i..i + 2], 16))
201+
.collect::<Result<Vec<u8>, _>>())
202+
.ok()?;
203+
204+
Some((message, signature))
205+
} else {
206+
None
207+
}
208+
}
209+
210+
if let Some((message, signature)) = message_and_signature(request) {
211+
let mut mac = Hmac::<sha2::Sha256>::new_varkey(secret).expect("");
212+
mac.update(&message);
213+
mac.verify(&signature).is_ok()
214+
} else {
215+
false
216+
}
217+
}
158218
}
159219

160220
/// Errors that can happen when parsing payload
@@ -517,5 +577,37 @@ fn test_verification_response() {
517577

518578
let val = dbg!(crate::eventsub::Payload::parse(&body).unwrap());
519579
crate::tests::roundtrip(&val)
520-
dbg!(crate::eventsub::Payload::parse(&body).unwrap());
580+
}
581+
582+
#[test]
583+
fn verify_request() {
584+
use http::header::{HeaderMap, HeaderName, HeaderValue};
585+
586+
let secret = b"secretabcd";
587+
#[rustfmt::skip]
588+
let headers: HeaderMap = vec![
589+
("Content-Length", "458"),
590+
("Content-Type", "application/json"),
591+
("Twitch-Eventsub-Message-Id", "ae2ff348-e102-16be-a3eb-6830c1bf38d2"),
592+
("Twitch-Eventsub-Message-Retry", "0"),
593+
("Twitch-Eventsub-Message-Signature", "sha256=d10f5bd9474b7ac7bd7105eb79c2d52768b4d0cd2a135982c3bf5a1d59a78823"),
594+
("Twitch-Eventsub-Message-Timestamp", "2021-02-19T23:47:00.8091512Z"),
595+
("Twitch-Eventsub-Message-Type", "notification"),
596+
("Twitch-Eventsub-Subscription-Type", "channel.follow"),
597+
("Twitch-Eventsub-Subscription-Version", "1"),
598+
].into_iter()
599+
.map(|(h, v)| {
600+
(
601+
h.parse::<HeaderName>().unwrap(),
602+
v.parse::<HeaderValue>().unwrap(),
603+
)
604+
})
605+
.collect();
606+
607+
let body = r#"{"subscription":{"id":"ae2ff348-e102-16be-a3eb-6830c1bf38d2","status":"enabled","type":"channel.follow","version":"1","condition":{"broadcaster_user_id":"44429626"},"transport":{"method":"webhook","callback":"null"},"created_at":"2021-02-19T23:47:00.7621315Z"},"event":{"user_id":"28408015","user_login":"testFromUser","user_name":"testFromUser","broadcaster_user_id":"44429626","broadcaster_user_login":"44429626","broadcaster_user_name":"testBroadcaster"}}"#;
608+
let mut request = http::Request::builder();
609+
let _ = std::mem::replace(request.headers_mut().unwrap(), headers);
610+
let request = request.body(body.as_bytes().to_vec()).unwrap();
611+
dbg!(&body);
612+
assert!(crate::eventsub::Payload::verify_payload(&request, secret));
521613
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>pubsub</code></span> | Enables deserializable structs for [PubSub](pubsub) |
5959
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>surf_client</code></span> | Enables surf for [`HttpClient`] |
6060
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>reqwest_client</code></span> | Enables reqwest for [`HttpClient`] |
61+
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>hmac</code></span> | Enable [message authentication](eventsub::Payload::verify_payload) using HMAC on [EventSub](eventsub) |
6162
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>all</code></span> | Enables all above features. Including reqwest and surf. Do not use this in production, it's better if you specify exactly what you need |
6263
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>unsupported</code></span> | Enables undocumented or experimental endpoints or topics. Breakage may occur |
6364
//! | <span class="module-item stab portability" style="display: inline; border-radius: 3px; padding: 2px; font-size: 80%; line-height: 1.2;"><code>allow_unknown_fields</code></span> | Removes `#[serde(deny_unknown_fields)]` on all applicable structs/enums |

0 commit comments

Comments
 (0)