1use crate::http::Error;
56use crate::ids::UserId;
57use crate::lenient::{array, boolean, get, int, string, uint};
58use base64::Engine;
59use rsa::pkcs1::DecodeRsaPublicKey;
60use rsa::pkcs1v15::{Signature, VerifyingKey};
61use rsa::pkcs8::DecodePublicKey;
62use rsa::signature::Verifier;
63use serde::Serialize;
64use serde_json::Value;
65use std::fmt;
66
67pub const DEFAULT_TOLERANCE_SECS: u64 = 300;
72
73const NAMES: [&str; 6] = [
74 "kick-event-message-id",
75 "kick-event-message-timestamp",
76 "kick-event-signature",
77 "kick-event-type",
78 "kick-event-version",
79 "kick-event-subscription-id",
80];
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[non_exhaustive]
85pub struct Headers {
86 pub message_id: String,
88 pub timestamp: String,
90 pub signature: String,
92 pub event_type: String,
94 pub version: String,
96 pub subscription_id: Option<String>,
98}
99
100#[derive(Debug, thiserror::Error, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum WebhookError {
104 #[error("missing header {0}")]
106 MissingHeader(&'static str),
107 #[error("malformed header {0}")]
110 MalformedHeader(&'static str),
111 #[error("unreadable public key")]
113 BadKey,
114 #[error("malformed signature")]
116 MalformedSignature,
117 #[error("signature does not match")]
119 Mismatch,
120 #[error("the delivery's timestamp is {offset_secs} s from now, beyond the tolerance")]
123 Stale {
124 offset_secs: i64,
126 },
127}
128
129impl Headers {
130 pub fn from_pairs<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> Result<Self, WebhookError> {
133 let mut found: [Option<String>; 6] = Default::default();
134 for (name, value) in pairs {
135 if let Some(i) = NAMES.iter().position(|n| n.eq_ignore_ascii_case(name)) {
136 found[i] = Some(value.to_owned());
137 }
138 }
139 let [id, ts, sig, ty, ver, sub] = found;
140 let headers = Headers {
141 message_id: id.ok_or(WebhookError::MissingHeader(NAMES[0]))?,
142 timestamp: ts.ok_or(WebhookError::MissingHeader(NAMES[1]))?,
143 signature: sig.ok_or(WebhookError::MissingHeader(NAMES[2]))?,
144 event_type: ty.ok_or(WebhookError::MissingHeader(NAMES[3]))?,
145 version: ver.ok_or(WebhookError::MissingHeader(NAMES[4]))?,
146 subscription_id: sub,
147 };
148 headers.check()?;
149 Ok(headers)
150 }
151
152 fn check(&self) -> Result<i64, WebhookError> {
154 if !is_ulid(&self.message_id) {
155 return Err(WebhookError::MalformedHeader(NAMES[0]));
156 }
157 rfc3339_secs(&self.timestamp).ok_or(WebhookError::MalformedHeader(NAMES[1]))
158 }
159}
160
161fn is_ulid(s: &str) -> bool {
163 s.len() == 26
164 && s.bytes().enumerate().all(|(i, b)| {
165 let b = b.to_ascii_uppercase();
166 match i {
167 0 => (b'0'..=b'7').contains(&b),
168 _ => b.is_ascii_digit() || (b.is_ascii_uppercase() && !matches!(b, b'I' | b'L' | b'O' | b'U')),
169 }
170 })
171}
172
173fn rfc3339_secs(s: &str) -> Option<i64> {
176 let b = s.as_bytes();
177 let num = |from: usize, len: usize| -> Option<i64> {
178 let part = b.get(from..from + len)?;
179 part.iter().all(u8::is_ascii_digit).then(|| part.iter().fold(0i64, |n, d| n * 10 + i64::from(d - b'0')))
180 };
181 let sep = |at: usize, c: &[u8]| b.get(at).is_some_and(|x| c.contains(x));
182 if !(sep(4, b"-") && sep(7, b"-") && sep(10, b"Tt") && sep(13, b":") && sep(16, b":")) {
183 return None;
184 }
185 let (year, month, day) = (num(0, 4)?, num(5, 2)?, num(8, 2)?);
186 let (hour, minute, second) = (num(11, 2)?, num(14, 2)?, num(17, 2)?);
187 let mut rest = 19;
188 if sep(rest, b".") {
189 let digits = b[rest + 1..].iter().take_while(|d| d.is_ascii_digit()).count();
190 if digits == 0 {
191 return None;
192 }
193 rest += 1 + digits;
194 }
195 let offset = match b.get(rest..)? {
196 [b'Z' | b'z'] => 0,
197 [sign @ (b'+' | b'-'), _, _, b':', _, _] => {
198 let (h, m) = (num(rest + 1, 2)?, num(rest + 4, 2)?);
199 if h > 23 || m > 59 {
200 return None;
201 }
202 (h * 60 + m) * 60 * if *sign == b'-' { -1 } else { 1 }
203 }
204 _ => return None,
205 };
206 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
207 let days_in_month = match month {
208 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
209 4 | 6 | 9 | 11 => 30,
210 2 if leap => 29,
211 2 => 28,
212 _ => return None,
213 };
214 if day == 0 || day > days_in_month || hour > 23 || minute > 59 || second > 60 {
215 return None;
216 }
217 let y = if month <= 2 { year - 1 } else { year };
219 let era = y.div_euclid(400);
220 let yoe = y - era * 400;
221 let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
222 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
223 let days = era * 146_097 + doe - 719_468;
224 Some(days * 86_400 + hour * 3_600 + minute * 60 + second - offset)
225}
226
227#[derive(Clone)]
229pub struct PublicKey(VerifyingKey<sha2::Sha256>);
230
231impl PartialEq for PublicKey {
232 fn eq(&self, other: &Self) -> bool {
233 AsRef::<rsa::RsaPublicKey>::as_ref(&self.0) == other.0.as_ref()
234 }
235}
236
237impl Eq for PublicKey {}
238
239impl PublicKey {
240 pub fn from_pem(pem: &str) -> Result<Self, WebhookError> {
243 let pem = pem.trim();
244 let key = rsa::RsaPublicKey::from_public_key_pem(pem)
245 .or_else(|_| rsa::RsaPublicKey::from_pkcs1_pem(pem))
246 .map_err(|_| WebhookError::BadKey)?;
247 Ok(Self(VerifyingKey::new(key)))
248 }
249}
250
251impl std::str::FromStr for PublicKey {
252 type Err = WebhookError;
253 fn from_str(pem: &str) -> Result<Self, WebhookError> {
254 Self::from_pem(pem)
255 }
256}
257
258impl fmt::Debug for PublicKey {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.write_str("PublicKey(RSA)")
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266#[non_exhaustive]
267pub enum Freshness {
268 Within {
271 now: u64,
273 tolerance_secs: u64,
275 },
276 Skip,
279}
280
281impl Freshness {
282 pub fn at(now: u64) -> Self {
284 Freshness::Within { now, tolerance_secs: DEFAULT_TOLERANCE_SECS }
285 }
286}
287
288pub fn verify(
292 key: &PublicKey,
293 headers: &Headers,
294 body: impl AsRef<[u8]>,
295 freshness: Freshness,
296) -> Result<(), WebhookError> {
297 let timestamp = headers.check()?;
298 if let Freshness::Within { now, tolerance_secs } = freshness {
299 let offset = i128::from(timestamp) - i128::from(now);
300 if offset.unsigned_abs() > u128::from(tolerance_secs) {
301 let offset_secs = i64::try_from(offset).unwrap_or(if offset < 0 { i64::MIN } else { i64::MAX });
302 return Err(WebhookError::Stale { offset_secs });
303 }
304 }
305 let raw = base64::engine::general_purpose::STANDARD
306 .decode(headers.signature.trim())
307 .map_err(|_| WebhookError::MalformedSignature)?;
308 let signature = Signature::try_from(raw.as_slice()).map_err(|_| WebhookError::MalformedSignature)?;
309 let body = body.as_ref();
310 let mut signed = Vec::with_capacity(headers.message_id.len() + headers.timestamp.len() + body.len() + 2);
311 signed.extend_from_slice(headers.message_id.as_bytes());
312 signed.push(b'.');
313 signed.extend_from_slice(headers.timestamp.as_bytes());
314 signed.push(b'.');
315 signed.extend_from_slice(body);
316 key.0.verify(&signed, &signature).map_err(|_| WebhookError::Mismatch)
317}
318
319pub fn verify_pem(
322 public_key_pem: &str,
323 headers: &Headers,
324 body: impl AsRef<[u8]>,
325 freshness: Freshness,
326) -> Result<(), WebhookError> {
327 verify(&PublicKey::from_pem(public_key_pem)?, headers, body, freshness)
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333#[non_exhaustive]
334pub struct Person {
335 pub user_id: Option<UserId>,
337 pub username: Option<String>,
339 pub channel_slug: Option<String>,
341 pub is_verified: Option<bool>,
343 pub is_anonymous: Option<bool>,
345 pub profile_picture: Option<String>,
347}
348
349impl Person {
350 fn from(v: Option<&Value>) -> Option<Self> {
351 let v = v?;
352 Some(Person {
353 user_id: uint(v, "user_id").map(UserId),
354 username: string(v, "username"),
355 channel_slug: string(v, "channel_slug"),
356 is_verified: boolean(v, "is_verified"),
357 is_anonymous: boolean(v, "is_anonymous"),
358 profile_picture: string(v, "profile_picture"),
359 })
360 }
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
365#[non_exhaustive]
366pub struct Category {
367 pub id: Option<u64>,
369 pub name: Option<String>,
371 pub thumbnail: Option<String>,
373}
374
375#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
377#[serde(tag = "type", rename_all = "snake_case")]
378#[non_exhaustive]
379pub enum WebhookEvent {
380 LivestreamStatus {
383 broadcaster: Option<Person>,
385 is_live: bool,
387 title: Option<String>,
389 started_at: Option<String>,
391 ended_at: Option<String>,
393 },
394 LivestreamMetadata {
397 broadcaster: Option<Person>,
399 title: Option<String>,
401 language: Option<String>,
403 has_mature_content: Option<bool>,
405 category: Option<Category>,
407 },
408 Followed {
410 broadcaster: Option<Person>,
412 follower: Option<Person>,
414 },
415 Subscription {
417 renewal: bool,
419 broadcaster: Option<Person>,
421 subscriber: Option<Person>,
423 duration: Option<i64>,
425 created_at: Option<String>,
427 expires_at: Option<String>,
429 },
430 SubscriptionGifts {
432 broadcaster: Option<Person>,
434 gifter: Option<Person>,
436 giftees: Vec<Person>,
438 created_at: Option<String>,
440 expires_at: Option<String>,
442 },
443 KicksGifted {
445 broadcaster: Option<Person>,
447 sender: Option<Person>,
449 amount: Option<i64>,
451 name: Option<String>,
453 kind: Option<String>,
455 tier: Option<String>,
457 message: Option<String>,
459 pinned_time_seconds: Option<i64>,
461 created_at: Option<String>,
463 },
464 Other {
468 event_type: String,
470 body: Value,
472 },
473}
474
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
479enum Shape {
480 Status,
481 Metadata,
482 Followed,
483 Subscription,
484 Gifts,
485 Kicks,
486}
487
488impl Shape {
489 fn of_type(event_type: &str) -> Option<Shape> {
490 Some(match event_type {
491 "livestream.status.updated" => Shape::Status,
492 "livestream.metadata.updated" => Shape::Metadata,
493 "channel.followed" => Shape::Followed,
494 "channel.subscription.new" | "channel.subscription.renewal" => Shape::Subscription,
495 "channel.subscription.gifts" => Shape::Gifts,
496 "kicks.gifted" => Shape::Kicks,
497 _ => return None,
498 })
499 }
500
501 fn fits(self, v: &Value) -> bool {
503 let object = |key| get(v, key).is_some_and(Value::is_object);
504 match self {
505 Shape::Status => boolean(v, "is_live").is_some(),
506 Shape::Metadata => get(v, "metadata").is_some_and(|m| {
508 m.is_object()
509 && ["title", "language", "has_mature_content", "category"].iter().any(|k| m.get(k).is_some())
510 }),
511 Shape::Followed => object("follower"),
512 Shape::Subscription => object("subscriber"),
513 Shape::Gifts => get(v, "giftees").is_some_and(Value::is_array),
514 Shape::Kicks => object("gift"),
515 }
516 }
517}
518
519pub fn parse(event_type: &str, body: impl AsRef<[u8]>) -> Result<WebhookEvent, Error> {
528 let v: Value = serde_json::from_slice(body.as_ref())?;
529 if !v.is_object() {
530 return Err(Error::shape("a webhook body is an object"));
531 }
532 if let Some(claimed) = Shape::of_type(event_type)
533 && !claimed.fits(&v)
534 {
535 return Err(Error::shape(format!("the body is not a {event_type}")));
536 }
537 let broadcaster = || Person::from(get(&v, "broadcaster"));
538 Ok(match event_type {
539 "livestream.status.updated" => WebhookEvent::LivestreamStatus {
540 broadcaster: broadcaster(),
541 is_live: boolean(&v, "is_live").unwrap_or(false),
542 title: string(&v, "title"),
543 started_at: string(&v, "started_at"),
544 ended_at: string(&v, "ended_at"),
545 },
546 "livestream.metadata.updated" => {
547 let m = get(&v, "metadata").cloned().unwrap_or_default();
548 let c = get(&m, "category").or_else(|| get(&m, "Category"));
549 WebhookEvent::LivestreamMetadata {
550 broadcaster: broadcaster(),
551 title: string(&m, "title"),
552 language: string(&m, "language"),
553 has_mature_content: boolean(&m, "has_mature_content"),
554 category: c.map(|c| Category {
555 id: uint(c, "id"),
556 name: string(c, "name"),
557 thumbnail: string(c, "thumbnail"),
558 }),
559 }
560 }
561 "channel.followed" => {
562 WebhookEvent::Followed { broadcaster: broadcaster(), follower: Person::from(get(&v, "follower")) }
563 }
564 "channel.subscription.new" | "channel.subscription.renewal" => WebhookEvent::Subscription {
565 renewal: event_type.ends_with("renewal"),
566 broadcaster: broadcaster(),
567 subscriber: Person::from(get(&v, "subscriber")),
568 duration: int(&v, "duration"),
569 created_at: string(&v, "created_at"),
570 expires_at: string(&v, "expires_at"),
571 },
572 "channel.subscription.gifts" => WebhookEvent::SubscriptionGifts {
573 broadcaster: broadcaster(),
574 gifter: Person::from(get(&v, "gifter")),
575 giftees: array(&v, "giftees").iter().filter_map(|g| Person::from(Some(g))).collect(),
576 created_at: string(&v, "created_at"),
577 expires_at: string(&v, "expires_at"),
578 },
579 "kicks.gifted" => {
580 let g = get(&v, "gift").cloned().unwrap_or_default();
581 WebhookEvent::KicksGifted {
582 broadcaster: broadcaster(),
583 sender: Person::from(get(&v, "sender")),
584 amount: int(&g, "amount"),
585 name: string(&g, "name"),
586 kind: string(&g, "type"),
587 tier: string(&g, "tier"),
588 message: string(&g, "message"),
589 pinned_time_seconds: int(&g, "pinned_time_seconds"),
590 created_at: string(&v, "created_at"),
591 }
592 }
593 other => WebhookEvent::Other { event_type: other.to_owned(), body: v },
594 })
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 const ID: &str = "01M38M9K3YRKV8E6Q68C18ZWG4";
602
603 #[test]
604 fn headers_in_any_case_and_missing_ones_named() {
605 let pairs = [
606 ("Kick-Event-Message-Id", ID),
607 ("kick-event-message-timestamp", "2026-09-24T02:35:42Z"),
608 ("KICK-EVENT-SIGNATURE", "c2ln"),
609 ("kick-event-type", "channel.followed"),
610 ("kick-event-version", "1"),
611 ];
612 let h = Headers::from_pairs(pairs).unwrap();
613 assert_eq!(h.event_type, "channel.followed");
614 assert_eq!(h.subscription_id, None);
615 assert_eq!(
616 Headers::from_pairs(pairs[1..].iter().copied()),
617 Err(WebhookError::MissingHeader("kick-event-message-id"))
618 );
619 }
620
621 #[test]
622 fn signed_headers_must_be_in_their_formats() {
623 let with = |id: &str, ts: &str| {
624 Headers::from_pairs([
625 ("kick-event-message-id", id),
626 ("kick-event-message-timestamp", ts),
627 ("kick-event-signature", "c2ln"),
628 ("kick-event-type", "channel.followed"),
629 ("kick-event-version", "1"),
630 ])
631 };
632 assert!(with(ID, "2026-09-24T02:35:42Z").is_ok());
633 assert!(with(&ID.to_lowercase(), "2026-09-24t02:35:42.123456+02:00").is_ok());
634 let bad_id = Err(WebhookError::MalformedHeader("kick-event-message-id"));
635 for id in [
636 "",
637 "01M",
638 "01M38M9K3YRKV8E6Q68C18ZWG4X",
639 "81M38M9K3YRKV8E6Q68C18ZWG4",
640 "01M38M9K3YRKV8E6Q68C18ZWG.",
641 "01M38M9K3YRKV8E6Q68C18ZWGI",
642 ] {
643 assert_eq!(with(id, "2026-09-24T02:35:42Z"), bad_id, "{id:?}");
644 }
645 let bad_ts = Err(WebhookError::MalformedHeader("kick-event-message-timestamp"));
646 for ts in [
647 "",
648 "2026-09-24T02:35:42",
649 "2026-09-24T02:35:42Z.",
650 "2026-09-24T02:35:42Z.x",
651 "2026-09-24 02:35:42Z",
652 "2026-02-30T02:35:42Z",
653 "2026-09-24T24:00:00Z",
654 "2026-09-24T02:35:42.Z",
655 "2026-09-24T02:35:42+0200",
656 "1758681342",
657 ] {
658 assert_eq!(with(ID, ts), bad_ts, "{ts:?}");
659 }
660 }
661
662 #[test]
663 fn rfc3339_as_unix_seconds() {
664 assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0));
665 assert_eq!(rfc3339_secs("2026-09-24T02:35:42Z"), Some(1_790_217_342));
666 assert_eq!(rfc3339_secs("2026-09-24T04:35:42.5+02:00"), Some(1_790_217_342));
667 assert_eq!(rfc3339_secs("2024-02-29T00:00:00Z"), Some(1_709_164_800));
668 assert_eq!(rfc3339_secs("2023-02-29T00:00:00Z"), None);
669 assert_eq!(rfc3339_secs("1969-12-31T23:59:59Z"), Some(-1));
670 }
671
672 #[test]
673 fn a_body_must_be_its_types_shape() {
674 let follow = r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2}}"#;
675 assert!(matches!(parse("channel.followed", follow), Ok(WebhookEvent::Followed { .. })));
676 assert!(matches!(parse("channel.followed", follow.as_bytes()), Ok(WebhookEvent::Followed { .. })));
677 for other in [
678 "livestream.status.updated",
679 "livestream.metadata.updated",
680 "channel.subscription.new",
681 "channel.subscription.gifts",
682 "kicks.gifted",
683 ] {
684 assert!(matches!(parse(other, follow), Err(Error::Shape(_))), "{other}");
685 }
686 let banned = r#"{"broadcaster":{},"moderator":{},"banned_user":{},"metadata":{"reason":"x"}}"#;
687 assert!(matches!(parse("livestream.metadata.updated", banned), Err(Error::Shape(_))));
688 assert!(matches!(parse("moderation.banned", banned), Ok(WebhookEvent::Other { .. })));
689 assert!(matches!(parse("channel.followed", "[]"), Err(Error::Shape(_))));
690 assert!(matches!(parse("channel.followed", "{"), Err(Error::Json(_))));
691 assert!(matches!(parse("channel.followed", b"\xff"), Err(Error::Json(_))));
692 }
693
694 const BODIES: [(&str, &str); 7] = [
697 (
698 "livestream.status.updated",
699 r#"{"broadcaster":{"user_id":1},"is_live":true,"title":"t","started_at":"2026-09-24T03:01:11Z","ended_at":null}"#,
700 ),
701 (
702 "livestream.metadata.updated",
703 r#"{"broadcaster":{"user_id":1},"metadata":{"title":"t","language":"en","has_mature_content":false,"category":{"id":15},"Category":{"id":15}}}"#,
704 ),
705 ("channel.followed", r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2}}"#),
706 (
707 "channel.subscription.new",
708 r#"{"broadcaster":{"user_id":1},"subscriber":{"user_id":2},"duration":1,"created_at":"x","expires_at":"y"}"#,
709 ),
710 (
711 "channel.subscription.renewal",
712 r#"{"broadcaster":{"user_id":1},"subscriber":{"user_id":2},"duration":3,"created_at":"x","expires_at":"y"}"#,
713 ),
714 (
715 "channel.subscription.gifts",
716 r#"{"broadcaster":{"user_id":1},"gifter":{"is_anonymous":true,"user_id":null},"giftees":[{"user_id":3}],"created_at":"x","expires_at":"y"}"#,
717 ),
718 (
719 "kicks.gifted",
720 r#"{"broadcaster":{"user_id":1},"sender":{"user_id":2},"gift":{"amount":10,"name":"n","type":"BASIC","tier":"BASIC","message":"m","pinned_time_seconds":0},"created_at":"x"}"#,
721 ),
722 ];
723
724 #[test]
725 fn no_known_body_passes_as_another_known_type() {
726 let subscription = |t: &str| t.starts_with("channel.subscription.") && !t.ends_with("gifts");
727 for (own, body) in BODIES {
728 assert!(parse(own, body).is_ok(), "{own}");
729 for (other, _) in BODIES {
730 if other == own || (subscription(own) && subscription(other)) {
732 continue;
733 }
734 assert!(matches!(parse(other, body), Err(Error::Shape(_))), "a {own} passed as a {other}");
735 }
736 }
737 for body in [
739 r#"{"broadcaster":{},"moderator":{},"banned_user":{},"metadata":{"reason":"x","created_at":"x","expires_at":null}}"#,
740 r#"{"id":"r","user_input":"u","status":"accepted","redeemed_at":"x","reward":{"id":"1","title":"t","cost":5},"redeemer":{},"broadcaster":{}}"#,
741 r#"{"message_id":"m","replies_to":null,"broadcaster":{},"sender":{},"content":"hi","emotes":[],"created_at":"x"}"#,
742 ] {
743 for (claimed, _) in BODIES {
744 assert!(matches!(parse(claimed, body), Err(Error::Shape(_))), "{claimed}: {body}");
745 }
746 }
747 }
748
749 #[test]
750 fn fields_kick_adds_later_are_ignored() {
751 let status = r#"{"broadcaster":{},"is_live":true,"started_at":"x","follower":{},"gift":{},"new_field":[1]}"#;
754 assert!(matches!(
755 parse("livestream.status.updated", status),
756 Ok(WebhookEvent::LivestreamStatus { is_live: true, .. })
757 ));
758 let follow = r#"{"broadcaster":{"user_id":1},"follower":{"user_id":2},"followed_at":"2026-10-01T00:00:00Z"}"#;
759 assert!(matches!(parse("channel.followed", follow), Ok(WebhookEvent::Followed { .. })));
760 }
761
762 const TEST_PRIVATE_KEY: &str =
767 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../fixtures/signing/throwaway-test-key.pem"));
768
769 fn signed(id: &str, ts: &str, body: &[u8]) -> (PublicKey, Headers) {
770 use rsa::pkcs8::DecodePrivateKey;
771 use rsa::signature::{SignatureEncoding, Signer};
772 let private = rsa::RsaPrivateKey::from_pkcs8_pem(TEST_PRIVATE_KEY).unwrap();
773 let public = PublicKey(VerifyingKey::new(private.to_public_key()));
774 let mut text = format!("{id}.{ts}.").into_bytes();
775 text.extend_from_slice(body);
776 let signature = rsa::pkcs1v15::SigningKey::<sha2::Sha256>::new(private).sign(&text).to_vec();
777 let signature = base64::engine::general_purpose::STANDARD.encode(signature);
778 let headers = Headers::from_pairs([
779 ("kick-event-message-id", id),
780 ("kick-event-message-timestamp", ts),
781 ("kick-event-signature", signature.as_str()),
782 ("kick-event-type", "channel.followed"),
783 ("kick-event-version", "1"),
784 ])
785 .unwrap();
786 (public, headers)
787 }
788
789 #[test]
790 fn bodies_verify_byte_for_byte_and_fresh() {
791 let at = 1_790_217_342; let body = b"{\"x\":\"\xff\xfe\"}";
794 let (key, headers) = signed(ID, "2026-09-24T02:35:42Z", body);
795 assert_eq!(verify(&key, &headers, body, Freshness::at(at)), Ok(()));
796 assert_eq!(verify(&key, &headers, Vec::from(&body[..]), Freshness::at(at + 300)), Ok(()));
797 assert_eq!(verify(&key, &headers, body, Freshness::at(at - 300)), Ok(()));
798 assert_eq!(
799 verify(&key, &headers, body, Freshness::at(at + 301)),
800 Err(WebhookError::Stale { offset_secs: -301 })
801 );
802 assert_eq!(
803 verify(&key, &headers, body, Freshness::at(at - 301)),
804 Err(WebhookError::Stale { offset_secs: 301 })
805 );
806 let tight = Freshness::Within { now: at + 10, tolerance_secs: 5 };
807 assert_eq!(verify(&key, &headers, body, tight), Err(WebhookError::Stale { offset_secs: -10 }));
808 assert_eq!(verify(&key, &headers, body, Freshness::Skip), Ok(()));
810 assert_eq!(verify(&key, &headers, b"{}", Freshness::Skip), Err(WebhookError::Mismatch));
811 let mut moved = headers.clone();
813 moved.timestamp = "2026-09-24T02:35:42Z.{".into();
814 assert_eq!(
815 verify(&key, &moved, body, Freshness::Skip),
816 Err(WebhookError::MalformedHeader("kick-event-message-timestamp"))
817 );
818 let mut moved = headers.clone();
819 moved.message_id = format!("{ID}.2026");
820 assert_eq!(
821 verify(&key, &moved, body, Freshness::Skip),
822 Err(WebhookError::MalformedHeader("kick-event-message-id"))
823 );
824 let mut garbled = headers;
825 garbled.signature = "not base64!".into();
826 assert_eq!(verify(&key, &garbled, body, Freshness::Skip), Err(WebhookError::MalformedSignature));
827 }
828
829 #[test]
830 fn keys_are_read_once() {
831 assert_eq!(PublicKey::from_pem("nope"), Err(WebhookError::BadKey));
832 assert_eq!("nope".parse::<PublicKey>(), Err(WebhookError::BadKey));
833 }
834}