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