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        "chat_instance",
140    };
141
142    // Build JSON pairs
143    let mut pairs = Vec::new();
144    for (key, value) in url.query_pairs() {
145        let val = value.to_string();
146
147        // Determine the format based on whether it's a string prop or valid JSON
148        let formatted_pair = if STRING_PROPS.contains(key.as_ref()) {
149            // Use string format for specified string properties
150            format!("\"{}\":\"{}\"", key, val)
151        } else {
152            // Check if the value is valid JSON
153            if serde_json::from_str::<serde_json::Value>(&val).is_ok() {
154                // Use raw format for valid JSON
155                format!("\"{}\":{}", key, val)
156            } else {
157                // Use string format for non-JSON values
158                format!("\"{}\":\"{}\"", key, val)
159            }
160        };
161
162        pairs.push(formatted_pair);
163    }
164
165    // Create final JSON string
166    let json_str = format!("{{{}}}", pairs.join(","));
167
168    // Deserialize JSON into InitData struct
169    serde_json::from_str(&json_str).map_err(ParseDataError::InvalidSignature)
170}
171
172#[derive(Debug)]
173pub enum SignError {
174    CouldNotProcessSignature,
175    CouldNotProcessAuthTime(SystemTimeError),
176    InvalidQueryString(url::ParseError),
177}
178
179/// Sign signs passed payload using specified key. Function removes such
180/// technical parameters as "hash" and "auth_date".
181pub fn sign<T: AsRef<str>>(
182    payload: HashMap<String, String>,
183    bot_token: T,
184    auth_time: SystemTime,
185) -> Result<String, SignError> {
186    let mut pairs = payload
187        .iter()
188        .filter_map(|(k, v)| {
189            // Skip technical fields.
190            if k == "hash" || k == "auth_date" {
191                None
192            } else {
193                Some(format!("{}={}", k, v))
194            }
195        })
196        .collect::<Vec<String>>();
197
198    let auth_date = auth_time
199        .duration_since(UNIX_EPOCH)
200        .map_err(SignError::CouldNotProcessAuthTime)?
201        .as_secs();
202    // Append sign date.
203    pairs.push(format!("auth_date={}", auth_date));
204
205    // According to docs, we sort all the pairs in alphabetical order.
206    pairs.sort();
207
208    let payload = pairs.join("\n");
209
210    // First HMAC: Create secret key using "WebAppData"
211    let mut sk_hmac = HmacSha256::new_from_slice("WebAppData".as_bytes())
212        .map_err(|_| SignError::CouldNotProcessSignature)?;
213    sk_hmac.update(bot_token.as_ref().as_bytes());
214    let secret_key = sk_hmac.finalize().into_bytes();
215
216    // Second HMAC: Sign the payload using the secret key
217    let mut imp_hmac =
218        HmacSha256::new_from_slice(&secret_key).map_err(|_| SignError::CouldNotProcessSignature)?;
219    imp_hmac.update(payload.as_bytes());
220
221    // Get result and convert to hex string
222    let result = imp_hmac.finalize().into_bytes();
223
224    Ok(hex::encode(result))
225}
226
227pub fn sign_query_string<T: AsRef<str>>(
228    qs: T,
229    bot_token: T,
230    auth_time: SystemTime,
231) -> Result<String, SignError> {
232    let url = Url::parse(&format!("http://dummy.com?{}", qs.as_ref()))
233        .map_err(SignError::InvalidQueryString)?;
234
235    let mut params: HashMap<String, String> = HashMap::new();
236    for (key, value) in url.query_pairs() {
237        params.insert(key.to_string(), value.to_string());
238    }
239
240    sign(params, bot_token, auth_time)
241}
242
243#[derive(Debug)]
244pub enum ValidationError {
245    InvalidQueryString(url::ParseError),
246    UnexpectedFormat,
247    SignMissing,
248    AuthDateMissing,
249    Expired,
250    SignInvalid,
251}
252
253/// Validates passed init data. This method expects initData to be
254/// passed in the exact raw format as it could be found
255/// in window.Telegram.WebApp.initData. Returns `Ok` in case init data is
256/// signed correctly, and it is allowed to trust it.
257///
258/// Current code is implementation of algorithmic code described in official
259/// docs:
260/// https://core.telegram.org/bots/webapps#validating-data-received-via-the-web-app
261///
262/// # Arguments
263/// * `init_data` - init data passed from application
264/// * `token` - TWA bot secret token which was used to create init data
265/// * `exp_in` - maximum init data lifetime. It is strongly recommended to use this
266///   parameter. In case exp duration is None, function does not check if parameters are expired.
267pub fn validate<T: AsRef<str>>(
268    init_data: T,
269    bot_token: T,
270    exp_in: Duration,
271) -> Result<bool, ValidationError> {
272    // Parse passed init data as query string
273    let url = Url::parse(&format!("http://dummy.com?{}", init_data.as_ref()))
274        .map_err(ValidationError::InvalidQueryString)?;
275
276    let mut auth_date: Option<SystemTime> = None;
277    let mut hash: Option<String> = None;
278    let mut pairs = Vec::new();
279
280    // Iterate over all key-value pairs of parsed parameters
281    for (key, value) in url.query_pairs() {
282        // Store found sign
283        if key == "hash" {
284            hash = Some(value.to_string());
285            continue;
286        }
287        if key == "auth_date" {
288            if let Ok(timestamp) = value.parse::<u64>() {
289                auth_date = Some(UNIX_EPOCH + Duration::from_secs(timestamp));
290            }
291        }
292        // Append new pair
293        pairs.push(format!("{}={}", key, value));
294    }
295
296    // Sign is always required
297    let hash = hash.ok_or(ValidationError::SignMissing)?;
298
299    // In case expiration time is passed, we do additional parameters check
300    if exp_in != Duration::from_secs(0) {
301        // In case auth date is none, it means we cannot check if parameters are expired
302        let auth_date = auth_date.ok_or(ValidationError::AuthDateMissing)?;
303
304        // Check if init data is expired
305        if auth_date + exp_in < SystemTime::now() {
306            return Err(ValidationError::Expired);
307        }
308    }
309
310    // According to docs, we sort all the pairs in alphabetical order
311    pairs.sort();
312
313    // Calculate signature
314    let calculated_hash = sign_query_string(init_data, bot_token, auth_date.unwrap_or(UNIX_EPOCH))
315        .map_err(|_| ValidationError::UnexpectedFormat)?;
316
317    // In case our sign is not equal to found one, we should throw an error
318    if calculated_hash != hash {
319        return Err(ValidationError::SignInvalid);
320    }
321
322    Ok(true)
323}
324
325#[cfg(test)]
326mod tests {
327    use std::time::Duration;
328
329    use super::*;
330
331    #[test]
332    fn test_parse_valid_data() {
333        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";
334        let result = parse(init_data);
335        assert!(result.is_ok());
336        let data = result.unwrap();
337        assert_eq!(
338            data,
339            InitData {
340                auth_date: 1662771648,
341                can_send_after: None,
342                chat: None,
343                chat_type: None,
344                chat_instance: None,
345                hash: "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2"
346                    .to_string(),
347                query_id: Some("AAHdF6IQAAAAAN0XohDhrOrc".to_string()),
348                receiver: None,
349                start_param: Some("abc".to_string()),
350                user: Some(User {
351                    added_to_attachment_menu: None,
352                    allows_write_to_pm: None,
353                    is_premium: Some(true),
354                    first_name: "Vladislav".to_string(),
355                    id: 279058397,
356                    is_bot: None,
357                    last_name: Some("Kibenko".to_string()),
358                    language_code: Some("ru".to_string()),
359                    photo_url: None,
360                    username: Some("vdkfrost".to_string())
361                })
362            }
363        );
364    }
365
366    #[test]
367    fn test_parse_invalid_data() {
368        let init_data = "invalid data";
369        let result = parse(init_data);
370        assert!(result.is_err());
371    }
372
373    #[test]
374    fn test_sign_query_string() {
375        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";
376        let test_bot_token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
377        let test_sign_hash =
378            "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2".to_string();
379        let auth_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1662771648);
380
381        let result = sign_query_string(qs, test_bot_token, auth_time).unwrap();
382
383        assert_eq!(result, test_sign_hash);
384    }
385
386    #[test]
387    fn test_sign_query_string_no_date() {
388        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";
389        let test_bot_token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
390        let test_sign_hash =
391            "c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2".to_string();
392        let auth_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1662771648);
393
394        let result = sign_query_string(qs, test_bot_token, auth_time).unwrap();
395
396        assert_eq!(result, test_sign_hash);
397    }
398
399    #[test]
400    fn test_validate_success() {
401        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";
402        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
403        let exp_in = Duration::from_secs(1662771648);
404
405        assert!(matches!(validate(init_data, token, exp_in), Ok(true)));
406    }
407
408    #[test]
409    fn test_validate_expired() {
410        let init_data =
411            "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";
412        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
413        let exp_in = Duration::from_secs(86400);
414
415        assert!(matches!(
416            validate(init_data, token, exp_in),
417            Err(ValidationError::Expired)
418        ));
419    }
420
421    #[test]
422    fn test_validate_missing_hash() {
423        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";
424        let token = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
425        let exp_in = Duration::from_secs(86400);
426
427        assert!(matches!(
428            validate(init_data, token, exp_in),
429            Err(ValidationError::SignMissing)
430        ));
431    }
432}