init_data_rs/
third_party_validation.rs

1use crate::parse;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD as base64_engine;
3use base64::Engine as _;
4use ed25519_dalek::{Signature, Verifier, VerifyingKey};
5use hex::FromHex;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use crate::{InitData, InitDataError};
9
10const TEST_PUBLIC_KEY: &str = "40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec";
11const PROD_PUBLIC_KEY: &str = "e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d";
12
13/// Validates data for third-party use
14///
15/// If you need to share the data with a third party, they can validate the data without requiring access to your bot's token.
16/// Simply provide them with the data from the Telegram.WebApp.initData field and your `bot_id`.
17///
18/// See: <https://core.telegram.org/bots/webapps#validating-data-for-third-party-use>
19///
20/// Telegram provides the following Ed25519 public keys for signature verification:
21/// * `40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec` for test environment
22/// * `e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d` for production environment
23///
24/// # Arguments
25/// * `init_data` - Raw init data string from Telegram Mini App
26/// * `bot_id` - Bot ID
27/// * `expires_in` - Optional expiration time in seconds
28/// * `is_test` - Whether to use the test public key
29///
30/// # Returns
31/// * `Ok(InitData)` - Parsed and validated init data
32/// * `Err(InitDataError)` - Various validation or parsing errors
33///
34fn validate_third_party_with_signature(
35    init_data: &str,
36    bot_id: i64,
37    expires_in: Option<u64>,
38    is_test: bool,
39) -> Result<InitData, InitDataError> {
40    if init_data.is_empty() || !init_data.contains('=') {
41        return Err(InitDataError::UnexpectedFormat(
42            "init_data is empty or malformed".to_string(),
43        ));
44    }
45
46    let pairs: Vec<(String, String)> = url::form_urlencoded::parse(init_data.as_bytes())
47        .map(|(k, v)| (k.into_owned(), v.into_owned()))
48        .collect();
49
50    let mut signature_b64 = None;
51    let mut filtered_pairs: Vec<(String, String)> = Vec::new();
52    let mut auth_date: Option<u64> = None;
53    for (k, v) in pairs {
54        if k == "signature" {
55            signature_b64 = Some(v);
56        } else if k == "auth_date" {
57            auth_date = v.parse().ok();
58            filtered_pairs.push((k, v));
59        } else if k != "hash" {
60            filtered_pairs.push((k, v));
61        }
62    }
63    let signature_b64 = signature_b64.ok_or(InitDataError::SignatureMissing)?;
64
65    if let (Some(expires_in), Some(auth_date)) = (expires_in, auth_date) {
66        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
67        if auth_date + expires_in < now {
68            return Err(InitDataError::Expired);
69        }
70    }
71
72    filtered_pairs.sort_by(|a, b| a.0.cmp(&b.0));
73
74    let message = format!(
75        "{}:WebAppData\n{}",
76        bot_id,
77        filtered_pairs
78            .iter()
79            .map(|(k, v)| format!("{k}={v}"))
80            .collect::<Vec<_>>()
81            .join("\n")
82    );
83
84    let signature_bytes = base64_engine
85        .decode(signature_b64.as_bytes())
86        .map_err(|_| InitDataError::SignatureInvalid("Failed to decode signature from base64".to_string()))?;
87
88    let signature = Signature::from_slice(&signature_bytes)
89        .map_err(|_| InitDataError::SignatureInvalid("Failed to parse signature".to_string()))?;
90
91    let public_key_hex = if is_test { TEST_PUBLIC_KEY } else { PROD_PUBLIC_KEY };
92
93    let public_key_bytes = <[u8; 32]>::from_hex(public_key_hex)
94        .map_err(|_| InitDataError::SignatureInvalid("Failed to parse public key".to_string()))?;
95
96    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes)
97        .map_err(|_| InitDataError::SignatureInvalid("Failed to parse public key".to_string()))?;
98
99    verifying_key
100        .verify(message.as_bytes(), &signature)
101        .map_err(|_| InitDataError::SignatureInvalid("Failed to verify signature".to_string()))?;
102
103    // 9. If valid, parse into InitData and return Ok
104    let data = parse(init_data)?;
105    Ok(data)
106}
107
108/// Validates init data using both primary and third-party bot tokens.
109///
110/// Similar to `validate()`, but accepts an additional third-party bot token
111/// for validation. The init data is considered valid if it matches either token.
112///
113/// # Arguments
114/// * `init_data` - Raw init data string from Telegram Mini App
115/// * `bot_id` - Bot ID
116/// * `expires_in` - Optional expiration time in seconds
117///
118/// # Returns
119/// * `Ok(InitData)` - Parsed and validated init data
120/// * `Err(InitDataError)` - Various validation or parsing errors
121///
122/// # Example
123/// ```
124/// use init_data_rs::validate_third_party;
125///
126/// let init_data = "query_id=123&auth_date=1662771648&hash=...&signature=...";
127/// let result = validate_third_party(init_data, 1234567890, None);
128/// ```
129///
130/// # Errors
131///
132/// See `init_data_rs::parse` for possible errors
133pub fn validate_third_party(init_data: &str, bot_id: i64, expires_in: Option<u64>) -> Result<InitData, InitDataError> {
134    validate_third_party_with_signature(init_data, bot_id, expires_in, false)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    // With signature
141    const VALID_INIT_DATA: &str = "user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%20%2B%20-%20%3F%20%5C%2F%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%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=8134722200314281151&chat_type=private&auth_date=1733584787&hash=2174df5b000556d044f3f020384e879c8efcab55ddea2ced4eb752e93e7080d6&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ";
142    const BOT_ID: i64 = 7342037359;
143
144    #[test]
145    fn test_valid_third_party_signature() {
146        let result = validate_third_party(VALID_INIT_DATA, BOT_ID, None);
147        assert!(result.is_ok(), "Expected Ok, got {result:?}");
148    }
149
150    #[test]
151    fn test_invalid_signature() {
152        // Tamper with the signature
153        let tampered = VALID_INIT_DATA.replace(
154            "zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ",
155            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
156        );
157        let result = validate_third_party(&tampered, BOT_ID, None);
158        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
159    }
160
161    #[test]
162    fn test_third_party_invalid_base64_signature() {
163        let bad_data = "query_id=test&auth_date=123&signature=!!!notbase64!!!&hash=abc";
164        let bot_id = 123456;
165        let result = validate_third_party_with_signature(bad_data, bot_id, None, true);
166        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
167    }
168
169    #[test]
170    fn test_third_party_invalid_public_key() {
171        let valid_data = "query_id=test&auth_date=123&signature=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&hash=abc";
172        let bot_id = 123456;
173        // Use an invalid public key by temporarily changing the constant or by passing a custom function if your API allows
174        // For this test, you might need to expose a version of your function that takes a public key string
175        let result = validate_third_party_with_signature(valid_data, bot_id, None, true); // with a purposely broken key
176        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
177    }
178
179    #[test]
180    fn test_third_party_signature_verification_failure() {
181        // Use a valid base64 signature, but one that doesn't match the data
182        let bad_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]);
183        let bad_data = format!("query_id=test&auth_date=123&signature={bad_sig}&hash=abc");
184        let bot_id = 123456;
185        let result = validate_third_party_with_signature(&bad_data, bot_id, None, true);
186        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
187    }
188
189    #[test]
190    fn test_missing_signature() {
191        // Remove the signature field
192        let mut parts: Vec<&str> = VALID_INIT_DATA.split('&').collect();
193        parts.retain(|s| !s.starts_with("signature="));
194        let no_sig = parts.join("&");
195        let result = validate_third_party(&no_sig, BOT_ID, None);
196        assert!(matches!(result, Err(InitDataError::SignatureMissing)));
197    }
198
199    #[test]
200    fn test_expired_data() {
201        // Use a very old auth_date
202        let expired_data = VALID_INIT_DATA.replace("auth_date=1733584787", "auth_date=1000000000");
203        let result = validate_third_party(&expired_data, BOT_ID, Some(86400));
204        assert!(matches!(result, Err(InitDataError::Expired)));
205    }
206
207    #[test]
208    fn test_malformed_input() {
209        let result = validate_third_party("not_a_query_string", BOT_ID, None);
210        assert!(matches!(result, Err(InitDataError::UnexpectedFormat(_))));
211    }
212
213    #[test]
214    fn test_wrong_bot_id() {
215        // Use a wrong bot_id (signature won't match)
216        let result = validate_third_party(VALID_INIT_DATA, 1234567890, None);
217        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
218    }
219
220    #[test]
221    fn test_wrong_environment() {
222        // Use test environment (signature won't match prod key)
223        let result = validate_third_party_with_signature(VALID_INIT_DATA, BOT_ID, None, true);
224        assert!(matches!(result, Err(InitDataError::SignatureInvalid(_))));
225    }
226}