1use std::collections::BTreeMap;
2use std::fmt;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use hmac::{Hmac, Mac};
6use sha2::Sha256;
7use url::form_urlencoded;
8
9use crate::Error;
10
11type HmacSha256 = Hmac<Sha256>;
12const WEB_APP_DATA_KEY: &[u8] = b"WebAppData";
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16#[non_exhaustive]
17pub struct VerifiedWebAppInitData {
18 auth_date: Option<u64>,
19 fields: BTreeMap<String, String>,
20}
21
22impl VerifiedWebAppInitData {
23 pub fn auth_date(&self) -> Option<u64> {
24 self.auth_date
25 }
26
27 pub fn get(&self, key: &str) -> Option<&str> {
28 self.fields.get(key).map(String::as_str)
29 }
30
31 pub fn fields(&self) -> &BTreeMap<String, String> {
32 &self.fields
33 }
34
35 pub fn into_fields(self) -> BTreeMap<String, String> {
36 self.fields
37 }
38}
39
40pub fn parse_web_app_init_data(init_data: &str) -> Result<BTreeMap<String, String>, Error> {
42 if init_data.trim().is_empty() {
43 return Err(Error::InvalidRequest {
44 reason: "initData must not be empty".to_owned(),
45 });
46 }
47
48 let mut fields = BTreeMap::new();
49 for (key, value) in form_urlencoded::parse(init_data.as_bytes()) {
50 let key = key.into_owned();
51 let value = value.into_owned();
52 if fields.insert(key.clone(), value).is_some() {
53 return Err(Error::InvalidRequest {
54 reason: format!("initData contains duplicate key `{key}`"),
55 });
56 }
57 }
58
59 if fields.is_empty() {
60 return Err(Error::InvalidRequest {
61 reason: "initData does not contain any fields".to_owned(),
62 });
63 }
64
65 Ok(fields)
66}
67
68pub fn verify_web_app_init_data(
72 bot_token: &str,
73 init_data: &str,
74 max_age: Option<Duration>,
75) -> Result<VerifiedWebAppInitData, Error> {
76 if bot_token.trim().is_empty() {
77 return Err(Error::InvalidBotToken);
78 }
79
80 let mut fields = parse_web_app_init_data(init_data)?;
81 let hash_hex = fields.remove("hash").ok_or_else(|| Error::InvalidRequest {
82 reason: "initData is missing `hash`".to_owned(),
83 })?;
84
85 let data_check_string = fields
86 .iter()
87 .map(|(key, value)| format!("{key}={value}"))
88 .collect::<Vec<_>>()
89 .join("\n");
90
91 let secret_key = web_app_secret_key(bot_token)?;
92 let mut mac = HmacSha256::new_from_slice(secret_key.as_slice()).map_err(|error| {
93 Error::InvalidRequest {
94 reason: format!("failed to initialize initData verifier: {error}"),
95 }
96 })?;
97 mac.update(data_check_string.as_bytes());
98 let expected_hash = mac.finalize().into_bytes();
99
100 let actual_hash = decode_hex(hash_hex.as_str())?;
101 if !constant_time_eq(expected_hash.as_ref(), actual_hash.as_slice()) {
102 return Err(Error::InvalidRequest {
103 reason: "invalid initData signature".to_owned(),
104 });
105 }
106
107 let auth_date = match fields.get("auth_date") {
108 Some(value) => Some(
109 value
110 .parse::<u64>()
111 .map_err(|error| Error::InvalidRequest {
112 reason: format!("invalid initData `auth_date`: {error}"),
113 })?,
114 ),
115 None => None,
116 };
117
118 if let Some(max_age) = max_age {
119 let auth_date = auth_date.ok_or_else(|| Error::InvalidRequest {
120 reason: "initData is missing `auth_date` required for max_age validation".to_owned(),
121 })?;
122 let now = SystemTime::now()
123 .duration_since(UNIX_EPOCH)
124 .map_err(|error| Error::InvalidRequest {
125 reason: format!("system clock error while validating initData age: {error}"),
126 })?
127 .as_secs();
128 let age_secs = now.saturating_sub(auth_date);
129 if age_secs > max_age.as_secs() {
130 return Err(Error::InvalidRequest {
131 reason: format!(
132 "initData has expired: age={}s exceeds max_age={}s",
133 age_secs,
134 max_age.as_secs()
135 ),
136 });
137 }
138 }
139
140 Ok(VerifiedWebAppInitData { auth_date, fields })
141}
142
143fn web_app_secret_key(bot_token: &str) -> Result<[u8; 32], Error> {
144 let mut mac =
145 HmacSha256::new_from_slice(WEB_APP_DATA_KEY).map_err(|error| Error::InvalidRequest {
146 reason: format!("failed to derive Mini App secret key: {error}"),
147 })?;
148 mac.update(bot_token.as_bytes());
149 let secret = mac.finalize().into_bytes();
150 let mut output = [0_u8; 32];
151 output.copy_from_slice(secret.as_ref());
152 Ok(output)
153}
154
155fn decode_hex(input: &str) -> Result<Vec<u8>, Error> {
156 if !input.len().is_multiple_of(2) {
157 return Err(Error::InvalidRequest {
158 reason: "initData hash has invalid hex length".to_owned(),
159 });
160 }
161
162 let mut output = Vec::with_capacity(input.len() / 2);
163 let bytes = input.as_bytes();
164 let mut index = 0;
165 while index < bytes.len() {
166 let high = decode_hex_nibble(bytes[index]).ok_or_else(|| Error::InvalidRequest {
167 reason: "initData hash contains non-hex characters".to_owned(),
168 })?;
169 let low = decode_hex_nibble(bytes[index + 1]).ok_or_else(|| Error::InvalidRequest {
170 reason: "initData hash contains non-hex characters".to_owned(),
171 })?;
172 output.push((high << 4) | low);
173 index += 2;
174 }
175
176 Ok(output)
177}
178
179fn decode_hex_nibble(value: u8) -> Option<u8> {
180 match value {
181 b'0'..=b'9' => Some(value - b'0'),
182 b'a'..=b'f' => Some(value - b'a' + 10),
183 b'A'..=b'F' => Some(value - b'A' + 10),
184 _ => None,
185 }
186}
187
188fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
189 if left.len() != right.len() {
190 return false;
191 }
192
193 let mut diff = 0_u8;
194 for (lhs, rhs) in left.iter().zip(right.iter()) {
195 diff |= lhs ^ rhs;
196 }
197
198 diff == 0
199}
200
201#[derive(Clone, Eq, PartialEq)]
203#[non_exhaustive]
204pub enum Auth {
205 None,
207 BotToken(BotToken),
209}
210
211impl Auth {
212 pub const fn none() -> Self {
214 Self::None
215 }
216
217 pub fn bot_token(token: impl Into<String>) -> Result<Self, Error> {
219 Ok(Self::BotToken(BotToken::new(token)?))
220 }
221
222 pub(crate) fn token(&self) -> Option<&str> {
223 match self {
224 Self::None => None,
225 Self::BotToken(token) => Some(token.expose()),
226 }
227 }
228}
229
230impl fmt::Debug for Auth {
231 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232 match self {
233 Self::None => formatter.debug_tuple("Auth::None").finish(),
234 Self::BotToken(_) => formatter
235 .debug_struct("Auth::BotToken")
236 .field("token", &"<redacted>")
237 .finish(),
238 }
239 }
240}
241
242#[derive(Clone, Eq, PartialEq)]
244pub struct BotToken(String);
245
246impl BotToken {
247 pub fn new(token: impl Into<String>) -> Result<Self, Error> {
249 let token = token.into();
250 if token.trim().is_empty() {
251 return Err(Error::InvalidBotToken);
252 }
253
254 Ok(Self(token))
255 }
256
257 pub(crate) fn expose(&self) -> &str {
258 &self.0
259 }
260}
261
262impl fmt::Debug for BotToken {
263 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
264 formatter
265 .debug_struct("BotToken")
266 .field("value", &"<redacted>")
267 .finish()
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use std::error::Error as StdError;
274
275 use super::*;
276
277 fn sign_init_data(
278 bot_token: &str,
279 fields: &[(&str, &str)],
280 ) -> std::result::Result<String, Box<dyn StdError>> {
281 let mut ordered = BTreeMap::new();
282 for (key, value) in fields {
283 ordered.insert((*key).to_owned(), (*value).to_owned());
284 }
285
286 let data_check_string = ordered
287 .iter()
288 .map(|(key, value)| format!("{key}={value}"))
289 .collect::<Vec<_>>()
290 .join("\n");
291
292 let secret_key = web_app_secret_key(bot_token)?;
293 let mut mac = HmacSha256::new_from_slice(secret_key.as_slice())?;
294 mac.update(data_check_string.as_bytes());
295 let hash = mac.finalize().into_bytes();
296 let hash_hex = encode_hex(hash.as_ref());
297
298 let mut serializer = form_urlencoded::Serializer::new(String::new());
299 for (key, value) in ordered {
300 serializer.append_pair(&key, &value);
301 }
302 serializer.append_pair("hash", &hash_hex);
303 Ok(serializer.finish())
304 }
305
306 fn encode_hex(bytes: &[u8]) -> String {
307 const HEX: &[u8; 16] = b"0123456789abcdef";
308 let mut output = String::with_capacity(bytes.len() * 2);
309 for byte in bytes {
310 output.push(HEX[(byte >> 4) as usize] as char);
311 output.push(HEX[(byte & 0x0f) as usize] as char);
312 }
313 output
314 }
315
316 #[test]
317 fn verifies_valid_init_data() -> std::result::Result<(), Box<dyn StdError>> {
318 let bot_token = "123456:bot-token";
319 let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
320 let auth_date = now.to_string();
321 let init_data = sign_init_data(
322 bot_token,
323 &[
324 ("auth_date", auth_date.as_str()),
325 ("query_id", "q-1"),
326 ("user", r#"{"id":42,"first_name":"Tele"}"#),
327 ],
328 )?;
329
330 let verified =
331 verify_web_app_init_data(bot_token, init_data.as_str(), Some(Duration::from_secs(60)))?;
332 assert_eq!(verified.get("query_id"), Some("q-1"));
333 assert_eq!(verified.auth_date(), Some(now));
334 Ok(())
335 }
336
337 #[test]
338 fn verifies_known_hash_vector() -> std::result::Result<(), Box<dyn StdError>> {
339 let bot_token = "123456:bot-token";
340 let init_data = "auth_date=1700000000&query_id=q-1&user=%7B%22id%22%3A42%2C%22first_name%22%3A%22Tele%22%7D&hash=e6e77ddca82b669a27e3d2bacd6535954ced7219f791f47ff7f2e257000f6b1c";
341 let verified = verify_web_app_init_data(bot_token, init_data, None)?;
342 assert_eq!(verified.auth_date(), Some(1_700_000_000));
343 assert_eq!(verified.get("query_id"), Some("q-1"));
344 Ok(())
345 }
346
347 #[test]
348 fn rejects_invalid_signature() -> std::result::Result<(), Box<dyn StdError>> {
349 let bot_token = "123456:bot-token";
350 let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
351 let auth_date = now.to_string();
352 let mut init_data = sign_init_data(
353 bot_token,
354 &[("auth_date", auth_date.as_str()), ("query_id", "q-1")],
355 )?;
356 if let Some(hash_index) = init_data.find("hash=") {
357 let value_index = hash_index + 5;
358 if value_index < init_data.len() {
359 let replacement = match init_data.as_bytes()[value_index] {
360 b'0' => "1",
361 _ => "0",
362 };
363 init_data.replace_range(value_index..=value_index, replacement);
364 }
365 }
366
367 let error = match verify_web_app_init_data(bot_token, init_data.as_str(), None) {
368 Ok(_) => return Err("verification should fail".into()),
369 Err(error) => error,
370 };
371 assert!(matches!(error, Error::InvalidRequest { .. }));
372 assert!(error.to_string().contains("invalid initData signature"));
373 Ok(())
374 }
375
376 #[test]
377 fn rejects_stale_init_data() -> std::result::Result<(), Box<dyn StdError>> {
378 let bot_token = "123456:bot-token";
379 let stale_auth_date = "1";
380 let init_data = sign_init_data(
381 bot_token,
382 &[("auth_date", stale_auth_date), ("query_id", "q-1")],
383 )?;
384
385 let error = match verify_web_app_init_data(
386 bot_token,
387 init_data.as_str(),
388 Some(Duration::from_secs(60)),
389 ) {
390 Ok(_) => return Err("stale payload should fail".into()),
391 Err(error) => error,
392 };
393 assert!(matches!(error, Error::InvalidRequest { .. }));
394 assert!(error.to_string().contains("initData has expired"));
395 Ok(())
396 }
397
398 #[test]
399 fn rejects_duplicate_keys_in_init_data() -> std::result::Result<(), Box<dyn StdError>> {
400 let error = match parse_web_app_init_data("auth_date=1&auth_date=2&hash=deadbeef") {
401 Ok(_) => return Err("duplicate keys must be rejected".into()),
402 Err(error) => error,
403 };
404 assert!(matches!(error, Error::InvalidRequest { .. }));
405 assert!(error.to_string().contains("duplicate key `auth_date`"));
406 Ok(())
407 }
408}