tma_init_data/
lib.rs

1#![forbid(unsafe_code)]
2#![forbid(clippy::exit)]
3#![deny(clippy::pattern_type_mismatch)]
4#![warn(
5    clippy::future_not_send,
6    clippy::exhaustive_enums,
7    clippy::exhaustive_structs,
8    clippy::must_use_unit,
9    clippy::missing_inline_in_public_items,
10    clippy::must_use_candidate
11)]
12
13use hmac::{Hmac, Mac};
14use serde::Deserialize;
15use sha2::Sha256;
16use url::Url;
17
18use std::{
19    collections::HashMap,
20    time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH},
21};
22
23type HmacSha256 = Hmac<Sha256>;
24
25/// Contains launch parameters data
26/// https://docs.telegram-mini-apps.com/platform/init-data#parameters-list
27#[derive(Debug, PartialEq, Deserialize)]
28pub struct InitData {
29    /// The date the initialization data was created. Is a number representing a
30    /// Unix timestamp.
31    pub auth_date: u64,
32
33    /// The number of seconds after which a message can be sent via the method answerWebAppQuery.
34    pub can_send_after: Option<u64>,
35
36    /// An object containing data about the chat where the bot was launched via the attachment menu.
37    /// Returned for supergroups, channels and group chats - only for Mini Apps launched via the attachment menu.
38    pub chat: Option<Chat>,
39
40    /// The type of chat from which the Mini Apps was opened.
41    /// Returned only for applications opened by direct link.
42    pub chat_type: Option<String>,
43
44    /// A global identifier indicating the chat from which the Mini Apps was opened.
45    /// Returned only for applications opened by direct link.
46    pub chat_instance: Option<u64>,
47
48    /// Initialization data signature.
49    pub hash: String,
50
51    /// The unique session ID of the Mini App.
52    /// Used in the process of sending a message via the method answerWebAppQuery.
53    pub query_id: Option<String>,
54
55    /// An object containing data about the chat partner of the current user in the chat where the bot was launched via the attachment menu.
56    /// Returned only for private chats and only for Mini Apps launched via the attachment menu.
57    pub receiver: Option<User>,
58
59    /// The value of the startattach or startapp query parameter specified in the link.
60    /// It is returned only for Mini Apps opened through the attachment menu.
61    pub start_param: Option<String>,
62
63    /// An object containing information about the current user.
64    pub user: Option<User>,
65}
66
67/// Describes user information:
68/// https://docs.telegram-mini-apps.com/launch-parameters/init-data#user
69#[derive(Debug, PartialEq, Deserialize)]
70pub struct User {
71    /// True, if this user added the bot to the attachment menu.
72    pub added_to_attachment_menu: Option<bool>,
73
74    /// True, if this user allowed the bot to message them.
75    pub allows_write_to_pm: Option<bool>,
76
77    /// Has the user purchased Telegram Premium.
78    pub is_premium: Option<bool>,
79
80    /// Bot or user name.
81    pub first_name: String,
82
83    /// Bot or user ID.
84    pub id: i64,
85
86    /// Is the user a bot.
87    pub is_bot: Option<bool>,
88
89    /// User's last name.
90    pub last_name: Option<String>,
91
92    /// IETF user's language.
93    pub language_code: Option<String>,
94
95    /// Link to the user's or bot's photo. Photos can have formats `.jpeg` and `.svg`.
96    /// It is returned only for Mini Apps opened through the attachment menu.
97    pub photo_url: Option<String>,
98
99    /// Login of the bot or user.
100    pub username: Option<String>,
101}
102
103/// Describes the chat information.
104/// https://docs.telegram-mini-apps.com/platform/init-data#chat
105#[derive(Debug, PartialEq, Deserialize)]
106pub struct Chat {
107    /// Chat ID
108    pub id: i64,
109
110    /// Chat type
111    pub r#type: String,
112
113    /// Chat title
114    pub title: String,
115
116    /// Chat photo link. The photo can have .jpeg and .svg formats.
117    /// It is returned only for Mini Apps opened through the attachments menu.
118    pub photo_url: Option<String>,
119
120    /// Chat user login.
121    pub username: Option<String>,
122}
123
124#[derive(Debug)]
125pub enum ParseDataError {
126    InvalidSignature(serde_json::Error),
127    InvalidQueryString(url::ParseError),
128}
129
130/// Converts passed init data presented as query string to InitData object.
131pub fn parse<T: AsRef<str>>(init_data: T) -> Result<InitData, ParseDataError> {
132    // Parse passed init data as query string
133    let url = Url::parse(&format!("http://dummy.com?{}", init_data.as_ref()))
134        .map_err(ParseDataError::InvalidQueryString)?;
135
136    // Create a static HashSet of properties that should always be interpreted as strings
137    static STRING_PROPS: phf::Set<&'static str> = phf::phf_set! {
138        "start_param",
139    };
140
141    // Build JSON pairs
142    let mut pairs = Vec::new();
143    for (key, value) in url.query_pairs() {
144        let val = value.to_string();
145
146        // Determine the format based on whether it's a string prop or valid JSON
147        let formatted_pair = if STRING_PROPS.contains(key.as_ref()) {
148            // Use string format for specified string properties
149            format!("\"{}\":\"{}\"", key, val)
150        } else {
151            // Check if the value is valid JSON
152            if serde_json::from_str::<serde_json::Value>(&val).is_ok() {
153                // Use raw format for valid JSON
154                format!("\"{}\":{}", key, val)
155            } else {
156                // Use string format for non-JSON values
157                format!("\"{}\":\"{}\"", key, val)
158            }
159        };
160
161        pairs.push(formatted_pair);
162    }
163
164    // Create final JSON string
165    let json_str = format!("{{{}}}", pairs.join(","));
166
167    // Deserialize JSON into InitData struct
168    serde_json::from_str(&json_str).map_err(ParseDataError::InvalidSignature)
169}
170
171#[derive(Debug)]
172pub enum SignError {
173    CouldNotProcessSignature,
174    CouldNotProcessAuthTime(SystemTimeError),
175    InvalidQueryString(url::ParseError),
176}
177
178/// Sign signs passed payload using specified key. Function removes such
179/// technical parameters as "hash" and "auth_date".
180pub fn sign<T: AsRef<str>>(
181    payload: HashMap<String, String>,
182    bot_token: T,
183    auth_time: SystemTime,
184) -> Result<String, SignError> {
185    let mut pairs = payload
186        .iter()
187        .filter_map(|(k, v)| {
188            // Skip technical fields.
189            if k == "hash" || k == "auth_date" {
190                None
191            } else {
192                Some(format!("{}={}", k, v))
193            }
194        })
195        .collect::<Vec<String>>();
196
197    let auth_date = auth_time
198        .duration_since(UNIX_EPOCH)
199        .map_err(SignError::CouldNotProcessAuthTime)?
200        .as_secs();
201    // Append sign date.
202    pairs.push(format!("auth_date={}", auth_date));
203
204    // According to docs, we sort all the pairs in alphabetical order.
205    pairs.sort();
206
207    let payload = pairs.join("\n");
208
209    // First HMAC: Create secret key using "WebAppData"
210    let mut sk_hmac = HmacSha256::new_from_slice("WebAppData".as_bytes())
211        .map_err(|_| SignError::CouldNotProcessSignature)?;
212    sk_hmac.update(bot_token.as_ref().as_bytes());
213    let secret_key = sk_hmac.finalize().into_bytes();
214
215    // Second HMAC: Sign the payload using the secret key
216    let mut imp_hmac =
217        HmacSha256::new_from_slice(&secret_key).map_err(|_| SignError::CouldNotProcessSignature)?;
218    imp_hmac.update(payload.as_bytes());
219
220    // Get result and convert to hex string
221    let result = imp_hmac.finalize().into_bytes();
222
223    Ok(hex::encode(result))
224}
225
226pub fn sign_query_string<T: AsRef<str>>(
227    qs: T,
228    bot_token: T,
229    auth_time: SystemTime,
230) -> Result<String, SignError> {
231    let url = Url::parse(&format!("http://dummy.com?{}", qs.as_ref()))
232        .map_err(SignError::InvalidQueryString)?;
233
234    let mut params: HashMap<String, String> = HashMap::new();
235    for (key, value) in url.query_pairs() {
236        params.insert(key.to_string(), value.to_string());
237    }
238
239    sign(params, bot_token, auth_time)
240}
241
242#[derive(Debug)]
243pub enum ValidationError {
244    InvalidQueryString(url::ParseError),
245    UnexpectedFormat,
246    SignMissing,
247    AuthDateMissing,
248    Expired,
249    SignInvalid,
250}
251
252/// Validates passed init data. This method expects initData to be
253/// passed in the exact raw format as it could be found
254/// in window.Telegram.WebApp.initData. Returns `Ok` in case init data is
255/// signed correctly, and it is allowed to trust it.
256///
257/// Current code is implementation of algorithmic code described in official
258/// docs:
259/// https://core.telegram.org/bots/webapps#validating-data-received-via-the-web-app
260///
261/// # Arguments
262/// * `init_data` - init data passed from application
263/// * `token` - TWA bot secret token which was used to create init data
264/// * `exp_in` - maximum init data lifetime. It is strongly recommended to use this
265///   parameter. In case exp duration is None, function does not check if parameters are expired.
266pub fn validate<T: AsRef<str>>(
267    init_data: T,
268    bot_token: T,
269    exp_in: Duration,
270) -> Result<bool, ValidationError> {
271    // Parse passed init data as query string
272    let url = Url::parse(&format!("http://dummy.com?{}", init_data.as_ref()))
273        .map_err(ValidationError::InvalidQueryString)?;
274
275    let mut auth_date: Option<SystemTime> = None;
276    let mut hash: Option<String> = None;
277    let mut pairs = Vec::new();
278
279    // Iterate over all key-value pairs of parsed parameters
280    for (key, value) in url.query_pairs() {
281        // Store found sign
282        if key == "hash" {
283            hash = Some(value.to_string());
284            continue;
285        }
286        if key == "auth_date" {
287            if let Ok(timestamp) = value.parse::<u64>() {
288                auth_date = Some(UNIX_EPOCH + Duration::from_secs(timestamp));
289            }
290        }
291        // Append new pair
292        pairs.push(format!("{}={}", key, value));
293    }
294
295    // Sign is always required
296    let hash = hash.ok_or(ValidationError::SignMissing)?;
297
298    // In case expiration time is passed, we do additional parameters check
299    if exp_in != Duration::from_secs(0) {
300        // In case auth date is none, it means we cannot check if parameters are expired
301        let auth_date = auth_date.ok_or(ValidationError::AuthDateMissing)?;
302
303        // Check if init data is expired
304        if auth_date + exp_in < SystemTime::now() {
305            return Err(ValidationError::Expired);
306        }
307    }
308
309    // According to docs, we sort all the pairs in alphabetical order
310    pairs.sort();
311
312    // Calculate signature
313    let calculated_hash = sign_query_string(init_data, bot_token, auth_date.unwrap_or(UNIX_EPOCH))
314        .map_err(|_| ValidationError::UnexpectedFormat)?;
315
316    // In case our sign is not equal to found one, we should throw an error
317    if calculated_hash != hash {
318        return Err(ValidationError::SignInvalid);
319    }
320
321    Ok(true)
322}
323
324#[cfg(test)]
325mod tests {
326    use std::time::Duration;
327
328    use super::*;
329
330    #[test]
331    fn test_parse_valid_data() {
332        let init_data = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D&auth_date=1662771648&hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2&start_param=abc";
333        let result = parse(init_data);
334        assert!(result.is_ok());
335        let data = result.unwrap();
336        assert_eq!(
337            data,
338            InitData {
339                auth_date: 1662771648,
340                can_send_after: None,
341                chat: None,
342                chat_type: None,
343                chat_instance: None,
344                hash: "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2"
345                    .to_string(),
346                query_id: Some("AAHdF6IQAAAAAN0XohDhrOrc".to_string()),
347                receiver: None,
348                start_param: Some("abc".to_string()),
349                user: Some(User {
350                    added_to_attachment_menu: None,
351                    allows_write_to_pm: None,
352                    is_premium: Some(true),
353                    first_name: "Vladislav".to_string(),
354                    id: 279058397,
355                    is_bot: None,
356                    last_name: Some("Kibenko".to_string()),
357                    language_code: Some("ru".to_string()),
358                    photo_url: None,
359                    username: Some("vdkfrost".to_string())
360                })
361            }
362        );
363    }
364
365    #[test]
366    fn test_parse_invalid_data() {
367        let init_data = "invalid data";
368        let result = parse(init_data);
369        assert!(result.is_err());
370    }
371
372    #[test]
373    fn test_sign_query_string() {
374        let qs = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D&auth_date=1662771648&hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2";
375        let test_bot_token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
376        let test_sign_hash =
377            "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2".to_string();
378        let auth_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1662771648);
379
380        let result = sign_query_string(qs, test_bot_token, auth_time).unwrap();
381
382        assert_eq!(result, test_sign_hash);
383    }
384
385    #[test]
386    fn test_sign_query_string_no_date() {
387        let qs = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D";
388        let test_bot_token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
389        let test_sign_hash =
390            "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2".to_string();
391        let auth_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1662771648);
392
393        let result = sign_query_string(qs, test_bot_token, auth_time).unwrap();
394
395        assert_eq!(result, test_sign_hash);
396    }
397
398    #[test]
399    fn test_validate_success() {
400        let init_data = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D&auth_date=1662771648&hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2";
401        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
402        let exp_in = Duration::from_secs(1662771648);
403
404        assert!(matches!(validate(init_data, token, exp_in), Ok(true)));
405    }
406
407    #[test]
408    fn test_validate_expired() {
409        let init_data =
410            "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D&auth_date=1662771648&hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2";
411        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
412        let exp_in = Duration::from_secs(86400);
413
414        assert!(matches!(
415            validate(init_data, token, exp_in),
416            Err(ValidationError::Expired)
417        ));
418    }
419
420    #[test]
421    fn test_validate_missing_hash() {
422        let init_data = "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%7D&auth_date=1662771648";
423        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
424        let exp_in = Duration::from_secs(86400);
425
426        assert!(matches!(
427            validate(init_data, token, exp_in),
428            Err(ValidationError::SignMissing)
429        ));
430    }
431}