init_data_rs/
validation.rs

1//! Validation module for Telegram Mini Apps init data.
2//!
3//! This module provides functionality to validate the authenticity and integrity
4//! of init data passed from Telegram to Mini Apps. It includes support for both
5//! standard validation and third-party bot validation.
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::error::InitDataError;
10use crate::model::InitData;
11use crate::{parse, sign};
12
13/// Default expiration time for init data in seconds (24 hours)
14const DEFAULT_EXPIRATION: u64 = 86400;
15
16/// Extracts and validates the hash from init data string.
17///
18/// # Arguments
19/// * `init_data` - The raw init data string containing the hash
20///
21/// # Returns
22/// * `Ok((base_data, hash))` - Tuple containing the base data and valid hash
23/// * `Err(InitDataError)` - Error if hash is missing, invalid, or malformed
24fn extract_hash(init_data: &str) -> Result<(String, String), InitDataError> {
25    let (base_data, hash) = if let Some(pos) = init_data.find("&hash=") {
26        let (base, hash_part) = init_data.split_at(pos);
27        let hash = &hash_part[6..]; // Skip "&hash="
28        (base.to_string(), hash.to_string())
29    } else {
30        return Err(InitDataError::HashMissing);
31    };
32
33    if !hash.chars().all(|c| c.is_ascii_hexdigit()) || hash.len() != 64 {
34        return Err(InitDataError::HashInvalid);
35    }
36
37    Ok((base_data, hash))
38}
39
40/// Validates the authenticity and integrity of Telegram Mini Apps init data.
41///
42/// This function performs several checks:
43/// 1. Validates the format of the init data
44/// 2. Extracts and verifies the hash
45/// 3. Checks the data hasn't expired
46/// 4. Parses the data into a strongly-typed structure
47///
48/// # Arguments
49/// * `init_data` - Raw init data string from Telegram Mini App
50/// * `token` - Bot token used for validation
51/// * `expires_in` - Optional expiration time in seconds (defaults to 24 hours), set to 0 to disable expiration check
52///
53/// # Returns
54/// * `Ok(InitData)` - Parsed and validated init data
55/// * `Err(InitDataError)` - Various validation or parsing errors
56///
57/// # Example
58/// ```
59/// use init_data_rs::validate;
60///
61/// let init_data = "query_id=123&auth_date=1662771648&hash=...";
62/// let result = validate(init_data, "BOT_TOKEN", None);
63/// ```
64///
65/// # Errors
66///
67/// See `init_data_rs::parse` for possible errors
68///
69/// # Panics
70///
71/// This function panics if `SystemTime::now` returns a date less than `UNIX_EPOCH`.
72/// Meaning the function should panic only if the device time is really, REALLY bad.
73pub fn validate(init_data: &str, token: &str, expires_in: Option<u64>) -> Result<InitData, InitDataError> {
74    if init_data.is_empty() || !init_data.contains('=') {
75        return Err(InitDataError::UnexpectedFormat(
76            "init_data is empty or malformed".to_string(),
77        ));
78    }
79
80    let (base_data, hash) = extract_hash(init_data)?;
81
82    let expected_hash = sign(&base_data, token)?;
83
84    if hash != expected_hash {
85        return Err(InitDataError::HashInvalid);
86    }
87
88    let data = parse(init_data)?;
89
90    let expires_in = expires_in.unwrap_or(DEFAULT_EXPIRATION);
91    if expires_in > 0 {
92        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
93
94        if data.auth_date + expires_in < now {
95            return Err(InitDataError::Expired);
96        }
97    }
98
99    Ok(data)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    const BOT_TOKEN: &str = "5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8";
107    const INVALID_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
108    // Without signature
109    const VALID_INIT_DATA: &str = "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";
110
111    #[test]
112    fn test_validate_empty_data() {
113        let result = validate("", BOT_TOKEN, None);
114        assert!(matches!(result, Err(InitDataError::UnexpectedFormat(_))));
115    }
116
117    #[test]
118    fn test_validate_invalid_format() {
119        let result = validate("invalid_format", BOT_TOKEN, None);
120        assert!(matches!(result, Err(InitDataError::UnexpectedFormat(_))));
121    }
122
123    #[test]
124    fn test_validate_missing_hash() {
125        let data = "query_id=test&auth_date=123";
126        let token = "valid:token";
127        let result = validate(data, token, None);
128        assert!(matches!(result, Err(InitDataError::HashMissing)));
129    }
130
131    #[test]
132    fn test_validate_invalid_hash() {
133        let result = validate("query_id=test123&hash=invalid", BOT_TOKEN, None);
134        assert!(matches!(result, Err(InitDataError::HashInvalid)));
135    }
136
137    #[test]
138    fn test_validate_expired() {
139        let base_data = VALID_INIT_DATA
140            .replace("auth_date=1662771648", "auth_date=1000000000")
141            .replace(
142                "hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2",
143                "",
144            );
145
146        let hash = sign(&base_data, BOT_TOKEN).unwrap();
147        let init_data = format!("{base_data}&hash={hash}");
148        let result = validate(&init_data, BOT_TOKEN, Some(86400));
149        assert!(matches!(result, Err(InitDataError::Expired)));
150    }
151
152    #[test]
153    fn test_validate_no_expiration() {
154        let base_data = VALID_INIT_DATA
155            .replace("auth_date=1662771648", "auth_date=1000000000")
156            .replace(
157                "hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2",
158                "",
159            );
160
161        let hash = sign(&base_data, BOT_TOKEN).unwrap();
162        let init_data = format!("{base_data}&hash={hash}");
163        let result = validate(&init_data, BOT_TOKEN, Some(0));
164        println!("result: {result:?}");
165
166        assert!(result.is_ok());
167    }
168
169    #[test]
170    fn test_validate_valid_data() {
171        let result = validate(VALID_INIT_DATA, BOT_TOKEN, Some(0)); // Disable expiration check for test
172
173        assert!(result.is_ok());
174
175        let data = result.unwrap();
176        assert_eq!(data.auth_date, 1662771648);
177        assert!(data.user.is_some());
178
179        if let Some(user) = data.user {
180            assert_eq!(user.id, 279058397);
181            assert_eq!(user.first_name, "Vladislav");
182            assert_eq!(user.last_name, Some("Kibenko".to_string()));
183            assert_eq!(user.username, Some("vdkfrost".to_string()));
184            assert_eq!(user.language_code, Some("ru".to_string()));
185            assert_eq!(user.is_premium, Some(true));
186        }
187    }
188
189    #[test]
190    fn test_validate_malformed_hash() {
191        let result = validate("query_id=test123&hash=", BOT_TOKEN, None);
192        assert!(matches!(result, Err(InitDataError::HashInvalid)));
193    }
194
195    #[test]
196    fn test_validate_hash_format_length() {
197        let result = validate("query_id=test123&hash=abc123", BOT_TOKEN, None);
198        assert!(matches!(result, Err(InitDataError::HashInvalid)));
199
200        // Test hash that's too long
201        let result = validate(&format!("query_id=test123&hash={INVALID_HASH}0"), BOT_TOKEN, None);
202        assert!(matches!(result, Err(InitDataError::HashInvalid)));
203    }
204
205    #[test]
206    fn test_validate_hash_format_invalid_chars() {
207        // Test hash with invalid characters
208        let result = validate(
209            "query_id=test123&hash=gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg",
210            BOT_TOKEN,
211            None,
212        );
213        assert!(matches!(result, Err(InitDataError::HashInvalid)));
214    }
215
216    #[test]
217    fn test_validate_hash_extraction_failure() {
218        // Test case where hash= is at the end without a value
219        let result = validate("query_id=test123&hash=&other=value", BOT_TOKEN, None);
220        assert!(matches!(result, Err(InitDataError::HashInvalid)));
221
222        // Test case where hash= is in the middle without a value
223        let result = validate("query_id=test123&hash=&auth_date=123", BOT_TOKEN, None);
224        assert!(matches!(result, Err(InitDataError::HashInvalid)));
225    }
226
227    #[test]
228    fn test_validate_impossible_hash_extraction() {
229        // This test is for line 35
230        // We check for &hash= first, but try to force the else branch
231        let result = validate("query_id=test123&hash=abc\n&hash=def", BOT_TOKEN, None);
232        assert!(matches!(result, Err(InitDataError::HashInvalid)));
233    }
234
235    #[test]
236    fn test_validate_hash_extraction_corner_case() {
237        // Test case where hash= is at the end of string (no value, no other params)
238        let result = validate("query_id=test123&hash=", BOT_TOKEN, None);
239        assert!(matches!(result, Err(InitDataError::HashInvalid)));
240
241        // Test with escaped &
242        let result = validate("query_id=test123%26hash=abc", BOT_TOKEN, None);
243        assert!(matches!(result, Err(InitDataError::HashMissing)));
244
245        // Test with URL-encoded &hash=
246        let result = validate("query_id=test123%26hash%3Dabc", BOT_TOKEN, None);
247        assert!(matches!(result, Err(InitDataError::HashMissing)));
248    }
249
250    #[test]
251    fn test_extract_hash() {
252        // Test valid hash extraction
253        let init_data = "query_id=test123&hash=1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
254        let result = extract_hash(init_data);
255        assert!(result.is_ok());
256        let (base, hash) = result.unwrap();
257        assert_eq!(base, "query_id=test123");
258        assert_eq!(hash, "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef");
259
260        // Test missing hash
261        let result = extract_hash("query_id=test123");
262        assert!(matches!(result, Err(InitDataError::HashMissing)));
263
264        // Test invalid hash format
265        let result = extract_hash("query_id=test123&hash=invalid");
266        assert!(matches!(result, Err(InitDataError::HashInvalid)));
267    }
268
269    #[test]
270    fn test_validate_incorrect_hash() {
271        let base_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";
272        // Use an obviously invalid hash (all zeros)
273        let invalid_hash = "0000000000000000000000000000000000000000000000000000000000000000";
274        let init_data = format!("{base_data}&hash={invalid_hash}");
275        let result = validate(&init_data, BOT_TOKEN, None);
276        assert!(matches!(result, Err(InitDataError::HashInvalid)));
277    }
278}