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#[derive(Debug, PartialEq, Deserialize)]
28pub struct InitData {
29 pub auth_date: u64,
32
33 pub can_send_after: Option<u64>,
35
36 pub chat: Option<Chat>,
39
40 pub chat_type: Option<String>,
43
44 pub chat_instance: Option<u64>,
47
48 pub hash: String,
50
51 pub query_id: Option<String>,
54
55 pub receiver: Option<User>,
58
59 pub start_param: Option<String>,
62
63 pub user: Option<User>,
65}
66
67#[derive(Debug, PartialEq, Deserialize)]
70pub struct User {
71 pub added_to_attachment_menu: Option<bool>,
73
74 pub allows_write_to_pm: Option<bool>,
76
77 pub is_premium: Option<bool>,
79
80 pub first_name: String,
82
83 pub id: i64,
85
86 pub is_bot: Option<bool>,
88
89 pub last_name: Option<String>,
91
92 pub language_code: Option<String>,
94
95 pub photo_url: Option<String>,
98
99 pub username: Option<String>,
101}
102
103#[derive(Debug, PartialEq, Deserialize)]
106pub struct Chat {
107 pub id: i64,
109
110 pub r#type: String,
112
113 pub title: String,
115
116 pub photo_url: Option<String>,
119
120 pub username: Option<String>,
122}
123
124#[derive(Debug)]
125pub enum ParseDataError {
126 InvalidSignature(serde_json::Error),
127 InvalidQueryString(url::ParseError),
128}
129
130pub fn parse<T: AsRef<str>>(init_data: T) -> Result<InitData, ParseDataError> {
132 let url = Url::parse(&format!("http://dummy.com?{}", init_data.as_ref()))
134 .map_err(ParseDataError::InvalidQueryString)?;
135
136 static STRING_PROPS: phf::Set<&'static str> = phf::phf_set! {
138 "start_param",
139 "chat_instance",
140 };
141
142 let mut pairs = Vec::new();
144 for (key, value) in url.query_pairs() {
145 let val = value.to_string();
146
147 let formatted_pair = if STRING_PROPS.contains(key.as_ref()) {
149 format!("\"{}\":\"{}\"", key, val)
151 } else {
152 if serde_json::from_str::<serde_json::Value>(&val).is_ok() {
154 format!("\"{}\":{}", key, val)
156 } else {
157 format!("\"{}\":\"{}\"", key, val)
159 }
160 };
161
162 pairs.push(formatted_pair);
163 }
164
165 let json_str = format!("{{{}}}", pairs.join(","));
167
168 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
179pub 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 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 pairs.push(format!("auth_date={}", auth_date));
204
205 pairs.sort();
207
208 let payload = pairs.join("\n");
209
210 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 let mut imp_hmac =
218 HmacSha256::new_from_slice(&secret_key).map_err(|_| SignError::CouldNotProcessSignature)?;
219 imp_hmac.update(payload.as_bytes());
220
221 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
253pub fn validate<T: AsRef<str>>(
268 init_data: T,
269 bot_token: T,
270 exp_in: Duration,
271) -> Result<bool, ValidationError> {
272 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 for (key, value) in url.query_pairs() {
282 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 pairs.push(format!("{}={}", key, value));
294 }
295
296 let hash = hash.ok_or(ValidationError::SignMissing)?;
298
299 if exp_in != Duration::from_secs(0) {
301 let auth_date = auth_date.ok_or(ValidationError::AuthDateMissing)?;
303
304 if auth_date + exp_in < SystemTime::now() {
306 return Err(ValidationError::Expired);
307 }
308 }
309
310 pairs.sort();
312
313 let calculated_hash = sign_query_string(init_data, bot_token, auth_date.unwrap_or(UNIX_EPOCH))
315 .map_err(|_| ValidationError::UnexpectedFormat)?;
316
317 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}