Skip to main content

kicksdk/
webhooks.rs

1//! Kick's official webhooks: the headers, the signature, the bodies.
2//!
3//! A delivery is a POST with the event in the body and the rest in headers
4//! ([`Headers::from_pairs`]). Kick signs `<message id>.<timestamp>.<raw body>`
5//! with RSA (SHA-256, PKCS#1 v1.5); [`verify`] checks it against the key from
6//! [`crate::public_api::public_key_request`], read once into a [`PublicKey`].
7//!
8//! What a valid signature means, and what it does not:
9//!
10//! - **Kick sent this body, at that timestamp, under that message id, to
11//!   some app.** Not necessarily to yours: every app gets the same key, so
12//!   anyone who receives deliveries can replay one to you. Only the message
13//!   id, the timestamp and the body are signed.
14//! - **Replays**: [`verify`] refuses a timestamp more than
15//!   [`DEFAULT_TOLERANCE_SECS`] (5 minutes) from `now` ([`Freshness::at`]),
16//!   and the receiver should also keep the message ids it has seen for at
17//!   least that long and drop repeats - which Kick sends anyway (below).
18//!   [`Freshness::Skip`] is for re-checking deliveries stored earlier.
19//! - **The type, version and subscription-id headers are not signed.**
20//!   [`parse`] reads the body by the type header and refuses a body that
21//!   lacks that type's own field, so a delivery cannot be passed off as
22//!   another kind (no known type's body has another known type's own
23//!   field). Fields it does not know are ignored, so a field Kick adds
24//!   later does not break deliveries. Two cases it cannot tell apart:
25//!   `channel.subscription.new` and `.renewal` have one shape, and a type
26//!   this crate does not know comes out as [`WebhookEvent::Other`] with the
27//!   header's claim unchecked.
28//! - The message id must be a ULID and the timestamp RFC 3339
29//!   ([`WebhookError::MalformedHeader`]). Neither can then hold the `.`
30//!   that separates them in the signed text, so no header can move bytes
31//!   between the id, the timestamp and the body.
32//!
33//! What to expect (`docs/KICK.md` §4): the same event can arrive **twice**
34//! (key on the message id); a failed delivery has **not** been seen to be
35//! retried, so treat it as lost; metadata and follow bodies carry **no time**
36//! (use the timestamp header); a renewal's `created_at` is when the
37//! subscription first started.
38//!
39//! **Retries and freshness.** Kick's docs imply retries "for over a day"
40//! though none has been seen. If Kick does retry, a retry very likely keeps
41//! the original message id and timestamp (they are what is signed), so one
42//! that comes more than the tolerance later is refused as
43//! [`WebhookError::Stale`]. The default (5 minutes) suits a receiver that
44//! is up: live deliveries arrive within a second or two, and a short window
45//! keeps the set of message ids to remember small. A receiver that would
46//! rather take late retries than lose them can pass a longer tolerance
47//! ([`Freshness::Within`]), up to a day or more, and must then remember
48//! message ids for that long, since the window is also how long a captured
49//! delivery can be replayed.
50//!
51//! The RSA here is the `rsa` crate's, used for verifying only.
52//! RUSTSEC-2023-0071 (the Marvin timing attack) is about operations with the
53//! private key, which this crate never has; see `deny.toml`.
54
55use 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
67/// How far a delivery's timestamp may be from now, either way, by default:
68/// 5 minutes. Kick's deliveries arrive within a second or two; a retry, if
69/// Kick sends one, may carry the original timestamp and be refused as
70/// stale (see the module docs on choosing a tolerance).
71pub 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/// The headers of one delivery.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[non_exhaustive]
85pub struct Headers {
86    /// `Kick-Event-Message-Id`: a ULID. Deduplicate on it.
87    pub message_id: String,
88    /// `Kick-Event-Message-Timestamp`: RFC 3339, second precision.
89    pub timestamp: String,
90    /// `Kick-Event-Signature`: base64.
91    pub signature: String,
92    /// `Kick-Event-Type` (unsigned).
93    pub event_type: String,
94    /// `Kick-Event-Version` (unsigned).
95    pub version: String,
96    /// `Kick-Event-Subscription-Id` (unsigned).
97    pub subscription_id: Option<String>,
98}
99
100/// Why a delivery was refused.
101#[derive(Debug, thiserror::Error, PartialEq, Eq)]
102#[non_exhaustive]
103pub enum WebhookError {
104    /// A header is missing.
105    #[error("missing header {0}")]
106    MissingHeader(&'static str),
107    /// A header is not in its format: the message id a ULID, the timestamp
108    /// RFC 3339.
109    #[error("malformed header {0}")]
110    MalformedHeader(&'static str),
111    /// The public key could not be read.
112    #[error("unreadable public key")]
113    BadKey,
114    /// The signature is not base64 or not a signature.
115    #[error("malformed signature")]
116    MalformedSignature,
117    /// The signature does not match.
118    #[error("signature does not match")]
119    Mismatch,
120    /// The timestamp is further from now than the tolerance: a replay, or a
121    /// clock that is off.
122    #[error("the delivery's timestamp is {offset_secs} s from now, beyond the tolerance")]
123    Stale {
124        /// The timestamp less now, seconds: negative for the past.
125        offset_secs: i64,
126    },
127}
128
129impl Headers {
130    /// Reads the headers from (name, value) pairs, names in any case. The
131    /// message id must be a ULID and the timestamp RFC 3339.
132    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    /// The signed headers in their formats; the timestamp as Unix seconds.
153    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
161/// 26 characters of Crockford's base32, the first at most `7`.
162fn 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
173/// An RFC 3339 date-time (`2026-09-24T02:35:42Z`, a fraction and an offset
174/// allowed), as Unix seconds.
175fn 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    // Days from 1970-01-01 (Howard Hinnant's days_from_civil).
218    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/// Kick's webhook key, read once.
228#[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    /// Reads a PEM key: SubjectPublicKeyInfo (`BEGIN PUBLIC KEY`, what Kick
241    /// serves) or PKCS#1 (`BEGIN RSA PUBLIC KEY`).
242    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/// Whether, and how, [`verify`] checks the delivery's timestamp.
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266#[non_exhaustive]
267pub enum Freshness {
268    /// The timestamp must be at most `tolerance_secs` from `now`, before or
269    /// after.
270    Within {
271        /// Now, Unix seconds.
272        now: u64,
273        /// How far off the timestamp may be, seconds.
274        tolerance_secs: u64,
275    },
276    /// No check: for deliveries stored earlier and checked again. A live
277    /// receiver should never skip it.
278    Skip,
279}
280
281impl Freshness {
282    /// Within [`DEFAULT_TOLERANCE_SECS`] of `now` (Unix seconds).
283    pub fn at(now: u64) -> Self {
284        Freshness::Within { now, tolerance_secs: DEFAULT_TOLERANCE_SECS }
285    }
286}
287
288/// Checks a delivery: the headers' formats, the timestamp's freshness, and
289/// the signature. `body` is the raw body, byte for byte (`&[u8]`, `&str`,
290/// `Vec<u8>`, …): not re-encoded, and not necessarily UTF-8.
291pub 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
319/// [`verify`] with the key as PEM, read on every call. A receiver checking
320/// many deliveries should read it once with [`PublicKey::from_pem`].
321pub 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/// Someone in a webhook body: broadcaster, follower, subscriber, gifter,
331/// giftee, sender.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
333#[non_exhaustive]
334pub struct Person {
335    /// Their user id. Null for an anonymous gifter.
336    pub user_id: Option<UserId>,
337    /// Username. Null for an anonymous gifter.
338    pub username: Option<String>,
339    /// Channel slug.
340    pub channel_slug: Option<String>,
341    /// Verified.
342    pub is_verified: Option<bool>,
343    /// Anonymous (gifts).
344    pub is_anonymous: Option<bool>,
345    /// Their picture.
346    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/// A category in a metadata update.
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
365#[non_exhaustive]
366pub struct Category {
367    /// Kick's id.
368    pub id: Option<u64>,
369    /// Its name.
370    pub name: Option<String>,
371    /// Its picture.
372    pub thumbnail: Option<String>,
373}
374
375/// One webhook event, typed.
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
377#[serde(tag = "type", rename_all = "snake_case")]
378#[non_exhaustive]
379pub enum WebhookEvent {
380    /// `livestream.status.updated`: start (`is_live`, no `ended_at`) and end
381    /// are one type. Both carry the same `started_at`.
382    LivestreamStatus {
383        /// The channel.
384        broadcaster: Option<Person>,
385        /// Live now.
386        is_live: bool,
387        /// The title at that moment (not a source of title changes).
388        title: Option<String>,
389        /// The stream's start: with the channel, its identity.
390        started_at: Option<String>,
391        /// The end, on an end.
392        ended_at: Option<String>,
393    },
394    /// `livestream.metadata.updated`: a full snapshot; compare with the last
395    /// one to see what changed.
396    LivestreamMetadata {
397        /// The channel.
398        broadcaster: Option<Person>,
399        /// The title.
400        title: Option<String>,
401        /// The language.
402        language: Option<String>,
403        /// Mature.
404        has_mature_content: Option<bool>,
405        /// The category (sent as `category` and `Category`; same value).
406        category: Option<Category>,
407    },
408    /// `channel.followed`.
409    Followed {
410        /// The channel.
411        broadcaster: Option<Person>,
412        /// Who followed.
413        follower: Option<Person>,
414    },
415    /// `channel.subscription.new` and `.renewal`.
416    Subscription {
417        /// A renewal rather than a new subscription.
418        renewal: bool,
419        /// The channel.
420        broadcaster: Option<Person>,
421        /// Who.
422        subscriber: Option<Person>,
423        /// Months (1 for a new one).
424        duration: Option<i64>,
425        /// On a renewal, when the subscription first started.
426        created_at: Option<String>,
427        /// When it runs out.
428        expires_at: Option<String>,
429    },
430    /// `channel.subscription.gifts`: 1 to 25 giftees seen in one.
431    SubscriptionGifts {
432        /// The channel.
433        broadcaster: Option<Person>,
434        /// Who gave; `is_anonymous` with nulls for an anonymous gift.
435        gifter: Option<Person>,
436        /// Who got them.
437        giftees: Vec<Person>,
438        /// When.
439        created_at: Option<String>,
440        /// When they run out.
441        expires_at: Option<String>,
442    },
443    /// `kicks.gifted`.
444    KicksGifted {
445        /// The channel.
446        broadcaster: Option<Person>,
447        /// Who sent them.
448        sender: Option<Person>,
449        /// Kicks spent.
450        amount: Option<i64>,
451        /// The gift's name.
452        name: Option<String>,
453        /// `BASIC`, `LEVEL_UP`, …
454        kind: Option<String>,
455        /// `BASIC`, `MID`, …
456        tier: Option<String>,
457        /// The sender's message.
458        message: Option<String>,
459        /// How long it is pinned, seconds.
460        pinned_time_seconds: Option<i64>,
461        /// When.
462        created_at: Option<String>,
463    },
464    /// A type this crate does not read yet (`moderation.banned`,
465    /// `channel.reward.redemption.updated`, `chat.message.sent`, and anything
466    /// new): the body as sent.
467    Other {
468        /// The type header.
469        event_type: String,
470        /// The body.
471        body: Value,
472    },
473}
474
475/// The kinds of body this crate reads, by the field only that kind has.
476/// No known type's body has another known type's own field, so checking the
477/// claimed type's alone refuses every re-typing between them.
478#[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    /// Whether `v` has this kind's own field.
502    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            // moderation.banned has a `metadata` too, of other fields.
507            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
519/// Reads a delivery's body (raw bytes or text) by its type header.
520///
521/// The type header is not signed, so the body must have the type's own
522/// field (`follower` for a follow, `is_live` for a status, `gift` for
523/// Kicks, …); other fields, known or not, are ignored, so a field Kick adds
524/// later does not break a delivery. Otherwise [`Error::Shape`]; not JSON,
525/// [`Error::Json`]. A type this crate does not read comes out as
526/// [`WebhookEvent::Other`], unchecked.
527pub 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    /// One body of each type this crate reads, with the fields Kick sends
695    /// (KICK.md §4.1).
696    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                // `.new` and `.renewal` are one shape (see the module docs).
731                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        // The unread types' bodies do not pass as a read one either.
738        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        // Even a field another type owns: only the claimed type's own field
752        // is required.
753        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    /// A throwaway 1024-bit key made for this test alone, so a delivery can
763    /// be signed here: small, because only the arithmetic is under test.
764    /// It lives in fixtures/signing rather than here, so no package that
765    /// ships this file's source carries a private key.
766    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; // 2026-09-24T02:35:42Z
792        // Not UTF-8: verified as the bytes that came.
793        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        // A replay of a stored delivery, on purpose.
809        assert_eq!(verify(&key, &headers, body, Freshness::Skip), Ok(()));
810        assert_eq!(verify(&key, &headers, b"{}", Freshness::Skip), Err(WebhookError::Mismatch));
811        // Fields set by hand are checked as from_pairs checks them.
812        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}