foukoapi 0.1.2-alpha.1

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
Documentation
//! Telegram Mini App helpers: server-side validation of `initData`.
//!
//! A Mini App opened from a [`crate::Button::web_app`] button gets a signed
//! `initData` string from Telegram (via `window.Telegram.WebApp.initData`).
//! The app forwards it to your backend, which must check the signature
//! before trusting any field in it. This module does that check.
//!
//! Flow, end to end:
//!
//! ```ignore
//! // 1. Bot side: attach a Mini App button.
//! let kb = Keyboard::new().row([Button::web_app("Play", "https://app.example.com")]);
//!
//! // 2. Mini App side (JS): send Telegram.WebApp.initData to your backend.
//!
//! // 3. Backend side: validate before trusting anything in it.
//! let data = foukoapi::webapp::validate_init_data(
//!     &init_data,
//!     &bot_token,
//!     std::time::Duration::from_secs(12 * 3600),
//! )?;
//! if let Some(user) = &data.user {
//!     println!("verified user {}", user.id);
//! }
//! ```
//!
//! The signature scheme is Telegram's: `secret = HMAC_SHA256("WebAppData",
//! bot_token)`, then `hash = hex(HMAC_SHA256(secret, data_check_string))`
//! where the check string is every url-decoded `key=value` pair except
//! `hash`, sorted by key and joined with newlines. Comparison is
//! constant-time via `hmac`'s `verify_slice`.

use crate::{Error, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::Duration;

type HmacSha256 = Hmac<Sha256>;

/// Parsed, signature-checked Mini App init data.
#[derive(Debug, Clone)]
pub struct InitData {
    /// The user who opened the Mini App, if Telegram included one.
    pub user: Option<WebAppUser>,
    /// Unix time (seconds) when the init data was signed.
    pub auth_date: u64,
    /// Query id for `answerWebAppQuery`, present for inline-mode apps.
    pub query_id: Option<String>,
    /// Raw key/value pairs (url-decoded), hash excluded.
    pub fields: HashMap<String, String>,
}

/// The `user` object embedded in init data.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WebAppUser {
    /// Telegram user id.
    pub id: i64,
    /// First name; empty if Telegram omitted it.
    #[serde(default)]
    pub first_name: String,
    /// Last name, if set.
    #[serde(default)]
    pub last_name: Option<String>,
    /// Username without the `@`, if set.
    #[serde(default)]
    pub username: Option<String>,
    /// IETF language tag of the user's client, if known.
    #[serde(default)]
    pub language_code: Option<String>,
}

/// Validate `init_data` against `bot_token`. `max_age` guards against
/// replay of old signatures (Duration; use e.g. 12h in bots).
///
/// Returns the parsed fields only when the HMAC signature matches and
/// `auth_date` is within `max_age` of now. Errors are [`Error::Other`]
/// with "bad signature", "init data expired" or "malformed init data".
pub fn validate_init_data(init_data: &str, bot_token: &str, max_age: Duration) -> Result<InitData> {
    let mut pairs: Vec<(String, String)> = Vec::new();
    let mut hash: Option<String> = None;

    for chunk in init_data.split('&').filter(|c| !c.is_empty()) {
        let (raw_key, raw_value) = chunk
            .split_once('=')
            .ok_or_else(|| Error::Other("malformed init data: pair without '='".into()))?;
        let key = percent_decode(raw_key)?;
        let value = percent_decode(raw_value)?;
        if key == "hash" {
            hash = Some(value);
        } else {
            pairs.push((key, value));
        }
    }
    let hash = hash.ok_or_else(|| Error::Other("malformed init data: missing hash".into()))?;
    if pairs.is_empty() {
        return Err(Error::Other("malformed init data: no fields".into()));
    }

    // Telegram sorts pairs by key bytewise before signing.
    pairs.sort_by(|a, b| a.0.cmp(&b.0));
    let data_check_string = pairs
        .iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect::<Vec<_>>()
        .join("\n");

    let mut secret = HmacSha256::new_from_slice(b"WebAppData").expect("hmac accepts any key size");
    secret.update(bot_token.as_bytes());
    let secret_key = secret.finalize().into_bytes();

    let expected_hash = hex_decode(&hash)
        .ok_or_else(|| Error::Other("malformed init data: bad hash hex".into()))?;
    let mut mac = HmacSha256::new_from_slice(&secret_key).expect("hmac accepts any key size");
    mac.update(data_check_string.as_bytes());
    // verify_slice compares in constant time (subtle under the hood).
    mac.verify_slice(&expected_hash)
        .map_err(|_| Error::Other("bad signature".into()))?;

    let fields: HashMap<String, String> = pairs.into_iter().collect();

    let auth_date: u64 = fields
        .get("auth_date")
        .and_then(|v| v.parse().ok())
        .ok_or_else(|| Error::Other("malformed init data: bad auth_date".into()))?;
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    if now.saturating_sub(auth_date) > max_age.as_secs() {
        return Err(Error::Other("init data expired".into()));
    }

    let user = match fields.get("user") {
        Some(json) => Some(
            serde_json::from_str(json)
                .map_err(|e| Error::Other(format!("malformed init data: bad user json: {e}")))?,
        ),
        None => None,
    };
    let query_id = fields.get("query_id").cloned();

    Ok(InitData {
        user,
        auth_date,
        query_id,
        fields,
    })
}

/// Decode a percent-encoded component. Rejects broken escapes instead of
/// passing them through, so tampered encodings fail loudly. `+` stays
/// literal - Telegram encodes spaces as `%20`.
fn percent_decode(s: &str) -> Result<String> {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%' {
            let hi = bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16));
            let lo = bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16));
            match (hi, lo) {
                (Some(hi), Some(lo)) => {
                    out.push((hi * 16 + lo) as u8);
                    i += 3;
                }
                _ => {
                    return Err(Error::Other(
                        "malformed init data: bad percent-encoding".into(),
                    ))
                }
            }
        } else {
            out.push(bytes[i]);
            i += 1;
        }
    }
    String::from_utf8(out).map_err(|_| Error::Other("malformed init data: invalid utf-8".into()))
}

/// Decode a lowercase/uppercase hex string; `None` on odd length or
/// non-hex characters.
fn hex_decode(s: &str) -> Option<Vec<u8>> {
    if s.len() % 2 != 0 {
        return None;
    }
    s.as_bytes()
        .chunks(2)
        .map(|pair| {
            let hi = (pair[0] as char).to_digit(16)?;
            let lo = (pair[1] as char).to_digit(16)?;
            Some((hi * 16 + lo) as u8)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    const TOKEN: &str = "12345:TEST";

    fn hex_encode(bytes: &[u8]) -> String {
        bytes.iter().map(|b| format!("{b:02x}")).collect()
    }

    fn percent_encode(s: &str) -> String {
        let mut out = String::new();
        for b in s.bytes() {
            match b {
                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                    out.push(b as char)
                }
                _ => out.push_str(&format!("%{b:02X}")),
            }
        }
        out
    }

    /// Sign `pairs` the way Telegram does and build an initData string.
    fn make_init_data(pairs: &[(&str, &str)]) -> String {
        let mut sorted: Vec<_> = pairs.to_vec();
        sorted.sort_by(|a, b| a.0.cmp(b.0));
        let dcs = sorted
            .iter()
            .map(|(k, v)| format!("{k}={v}"))
            .collect::<Vec<_>>()
            .join("\n");

        let mut secret = HmacSha256::new_from_slice(b"WebAppData").unwrap();
        secret.update(TOKEN.as_bytes());
        let secret_key = secret.finalize().into_bytes();
        let mut mac = HmacSha256::new_from_slice(&secret_key).unwrap();
        mac.update(dcs.as_bytes());
        let hash = hex_encode(&mac.finalize().into_bytes());

        let mut query: Vec<String> = pairs
            .iter()
            .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
            .collect();
        query.push(format!("hash={hash}"));
        query.join("&")
    }

    fn now_secs() -> u64 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_secs()
    }

    const HOUR: Duration = Duration::from_secs(3600);

    #[test]
    fn valid_init_data_passes() {
        let auth = now_secs().to_string();
        let user = r#"{"id":42,"first_name":"Ann","username":"ann_dev","language_code":"en"}"#;
        let init = make_init_data(&[
            ("auth_date", &auth),
            ("user", user),
            ("query_id", "AAF3xyz"),
        ]);
        let data = validate_init_data(&init, TOKEN, HOUR).expect("should validate");
        let u = data.user.expect("user should be parsed");
        assert_eq!(u.id, 42);
        assert_eq!(u.first_name, "Ann");
        assert_eq!(u.username.as_deref(), Some("ann_dev"));
        assert_eq!(u.language_code.as_deref(), Some("en"));
        assert_eq!(data.query_id.as_deref(), Some("AAF3xyz"));
        assert_eq!(data.auth_date, auth.parse::<u64>().unwrap());
        // hash never leaks into the field map
        assert!(!data.fields.contains_key("hash"));
        assert_eq!(data.fields.get("user").map(String::as_str), Some(user));
    }

    #[test]
    fn forged_hash_is_rejected() {
        let auth = now_secs().to_string();
        let init = make_init_data(&[("auth_date", &auth)]);
        // Flip the hash to another valid-looking hex string.
        let forged = format!("{}{}", &init[..init.len() - 8], "deadbeef");
        let err = validate_init_data(&forged, TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("bad signature"));
    }

    #[test]
    fn tampered_field_is_rejected() {
        let auth = now_secs().to_string();
        let init = make_init_data(&[("auth_date", &auth), ("query_id", "AAA")]);
        let tampered = init.replace("query_id=AAA", "query_id=BBB");
        let err = validate_init_data(&tampered, TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("bad signature"));
    }

    #[test]
    fn expired_auth_date_is_rejected() {
        let old = (now_secs() - 7200).to_string();
        let init = make_init_data(&[("auth_date", &old)]);
        let err = validate_init_data(&init, TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("init data expired"));
    }

    #[test]
    fn missing_hash_is_rejected() {
        let err = validate_init_data("auth_date=123&query_id=AAA", TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("missing hash"));
    }

    #[test]
    fn broken_percent_encoding_is_rejected() {
        let err = validate_init_data("user=%GG&hash=00", TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("bad percent-encoding"));
        let err = validate_init_data("user=%2&hash=00", TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("bad percent-encoding"));
    }

    #[test]
    fn wrong_token_is_rejected() {
        let auth = now_secs().to_string();
        let init = make_init_data(&[("auth_date", &auth)]);
        let err = validate_init_data(&init, "999:OTHER", HOUR).unwrap_err();
        assert!(err.to_string().contains("bad signature"));
    }

    #[test]
    fn missing_auth_date_is_rejected() {
        let init = make_init_data(&[("query_id", "AAA")]);
        let err = validate_init_data(&init, TOKEN, HOUR).unwrap_err();
        assert!(err.to_string().contains("bad auth_date"));
    }
}