1use std::collections::BTreeMap;
2use std::fmt;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use graviola::hashing::{HashOutput, Sha256, hmac::Hmac};
6use url::form_urlencoded;
7
8use crate::Error;
9
10type HmacSha256 = Hmac<Sha256>;
11type Sha256Digest = [u8; 32];
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 expected_hash = hmac_sha256(secret_key, data_check_string.as_bytes());
93
94 let actual_hash = decode_hex(hash_hex.as_str())?;
95 if !expected_hash.ct_equal(actual_hash.as_slice()) {
96 return Err(Error::InvalidRequest {
97 reason: "invalid initData signature".to_owned(),
98 });
99 }
100
101 let auth_date = match fields.get("auth_date") {
102 Some(value) => Some(
103 value
104 .parse::<u64>()
105 .map_err(|error| Error::InvalidRequest {
106 reason: format!("invalid initData `auth_date`: {error}"),
107 })?,
108 ),
109 None => None,
110 };
111
112 if let Some(max_age) = max_age {
113 let auth_date = auth_date.ok_or_else(|| Error::InvalidRequest {
114 reason: "initData is missing `auth_date` required for max_age validation".to_owned(),
115 })?;
116 let now = SystemTime::now()
117 .duration_since(UNIX_EPOCH)
118 .map_err(|error| Error::InvalidRequest {
119 reason: format!("system clock error while validating initData age: {error}"),
120 })?
121 .as_secs();
122 let age_secs = now.saturating_sub(auth_date);
123 if age_secs > max_age.as_secs() {
124 return Err(Error::InvalidRequest {
125 reason: format!(
126 "initData has expired: age={}s exceeds max_age={}s",
127 age_secs,
128 max_age.as_secs()
129 ),
130 });
131 }
132 }
133
134 Ok(VerifiedWebAppInitData { auth_date, fields })
135}
136
137fn web_app_secret_key(bot_token: &str) -> Sha256Digest {
138 hmac_sha256_bytes(WEB_APP_DATA_KEY, bot_token.as_bytes())
139}
140
141fn hmac_sha256(key: impl AsRef<[u8]>, data: impl AsRef<[u8]>) -> HashOutput {
142 let mut mac = HmacSha256::new(key);
143 mac.update(data);
144 mac.finish()
145}
146
147fn hmac_sha256_bytes(key: impl AsRef<[u8]>, data: impl AsRef<[u8]>) -> Sha256Digest {
148 let mut output = [0_u8; 32];
149 output.copy_from_slice(hmac_sha256(key, data).as_ref());
150 output
151}
152
153fn decode_hex(input: &str) -> Result<Vec<u8>, Error> {
154 if !input.len().is_multiple_of(2) {
155 return Err(Error::InvalidRequest {
156 reason: "initData hash has invalid hex length".to_owned(),
157 });
158 }
159
160 let mut output = Vec::with_capacity(input.len() / 2);
161 let bytes = input.as_bytes();
162 let mut index = 0;
163 while index < bytes.len() {
164 let high = decode_hex_nibble(bytes[index]).ok_or_else(|| Error::InvalidRequest {
165 reason: "initData hash contains non-hex characters".to_owned(),
166 })?;
167 let low = decode_hex_nibble(bytes[index + 1]).ok_or_else(|| Error::InvalidRequest {
168 reason: "initData hash contains non-hex characters".to_owned(),
169 })?;
170 output.push((high << 4) | low);
171 index += 2;
172 }
173
174 Ok(output)
175}
176
177fn decode_hex_nibble(value: u8) -> Option<u8> {
178 match value {
179 b'0'..=b'9' => Some(value - b'0'),
180 b'a'..=b'f' => Some(value - b'a' + 10),
181 b'A'..=b'F' => Some(value - b'A' + 10),
182 _ => None,
183 }
184}
185
186#[derive(Clone, Eq, PartialEq)]
188#[non_exhaustive]
189pub enum Auth {
190 None,
192 BotToken(BotToken),
194}
195
196impl Auth {
197 pub const fn none() -> Self {
199 Self::None
200 }
201
202 pub fn bot_token(token: impl Into<String>) -> Result<Self, Error> {
204 Ok(Self::BotToken(BotToken::new(token)?))
205 }
206
207 pub(crate) fn token(&self) -> Option<&str> {
208 match self {
209 Self::None => None,
210 Self::BotToken(token) => Some(token.expose()),
211 }
212 }
213}
214
215impl fmt::Debug for Auth {
216 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
217 match self {
218 Self::None => formatter.debug_tuple("Auth::None").finish(),
219 Self::BotToken(_) => formatter
220 .debug_struct("Auth::BotToken")
221 .field("token", &"<redacted>")
222 .finish(),
223 }
224 }
225}
226
227#[derive(Clone, Eq, PartialEq)]
229pub struct BotToken(String);
230
231impl BotToken {
232 pub fn new(token: impl Into<String>) -> Result<Self, Error> {
234 let token = token.into();
235 if token.trim().is_empty() {
236 return Err(Error::InvalidBotToken);
237 }
238
239 Ok(Self(token))
240 }
241
242 pub(crate) fn expose(&self) -> &str {
243 &self.0
244 }
245}
246
247impl fmt::Debug for BotToken {
248 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
249 formatter
250 .debug_struct("BotToken")
251 .field("value", &"<redacted>")
252 .finish()
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use std::error::Error as StdError;
259
260 use super::*;
261
262 fn sign_init_data(
263 bot_token: &str,
264 fields: &[(&str, &str)],
265 ) -> std::result::Result<String, Box<dyn StdError>> {
266 let mut ordered = BTreeMap::new();
267 for (key, value) in fields {
268 ordered.insert((*key).to_owned(), (*value).to_owned());
269 }
270
271 let data_check_string = ordered
272 .iter()
273 .map(|(key, value)| format!("{key}={value}"))
274 .collect::<Vec<_>>()
275 .join("\n");
276
277 let secret_key = web_app_secret_key(bot_token);
278 let hash = hmac_sha256(secret_key, data_check_string.as_bytes());
279 let hash_hex = encode_hex(hash.as_ref());
280
281 let mut serializer = form_urlencoded::Serializer::new(String::new());
282 for (key, value) in ordered {
283 serializer.append_pair(&key, &value);
284 }
285 serializer.append_pair("hash", &hash_hex);
286 Ok(serializer.finish())
287 }
288
289 fn encode_hex(bytes: &[u8]) -> String {
290 const HEX: &[u8; 16] = b"0123456789abcdef";
291 let mut output = String::with_capacity(bytes.len() * 2);
292 for byte in bytes {
293 output.push(HEX[(byte >> 4) as usize] as char);
294 output.push(HEX[(byte & 0x0f) as usize] as char);
295 }
296 output
297 }
298
299 #[test]
300 fn verifies_valid_init_data() -> std::result::Result<(), Box<dyn StdError>> {
301 let bot_token = "123456:bot-token";
302 let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
303 let auth_date = now.to_string();
304 let init_data = sign_init_data(
305 bot_token,
306 &[
307 ("auth_date", auth_date.as_str()),
308 ("query_id", "q-1"),
309 ("user", r#"{"id":42,"first_name":"Tele"}"#),
310 ],
311 )?;
312
313 let verified =
314 verify_web_app_init_data(bot_token, init_data.as_str(), Some(Duration::from_secs(60)))?;
315 assert_eq!(verified.get("query_id"), Some("q-1"));
316 assert_eq!(verified.auth_date(), Some(now));
317 Ok(())
318 }
319
320 #[test]
321 fn verifies_known_hash_vector() -> std::result::Result<(), Box<dyn StdError>> {
322 let bot_token = "123456:bot-token";
323 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";
324 let verified = verify_web_app_init_data(bot_token, init_data, None)?;
325 assert_eq!(verified.auth_date(), Some(1_700_000_000));
326 assert_eq!(verified.get("query_id"), Some("q-1"));
327 Ok(())
328 }
329
330 #[test]
331 fn rejects_invalid_signature() -> std::result::Result<(), Box<dyn StdError>> {
332 let bot_token = "123456:bot-token";
333 let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
334 let auth_date = now.to_string();
335 let mut init_data = sign_init_data(
336 bot_token,
337 &[("auth_date", auth_date.as_str()), ("query_id", "q-1")],
338 )?;
339 if let Some(hash_index) = init_data.find("hash=") {
340 let value_index = hash_index + 5;
341 if value_index < init_data.len() {
342 let replacement = match init_data.as_bytes()[value_index] {
343 b'0' => "1",
344 _ => "0",
345 };
346 init_data.replace_range(value_index..=value_index, replacement);
347 }
348 }
349
350 let error = match verify_web_app_init_data(bot_token, init_data.as_str(), None) {
351 Ok(_) => return Err("verification should fail".into()),
352 Err(error) => error,
353 };
354 assert!(matches!(error, Error::InvalidRequest { .. }));
355 assert!(error.to_string().contains("invalid initData signature"));
356 Ok(())
357 }
358
359 #[test]
360 fn rejects_stale_init_data() -> std::result::Result<(), Box<dyn StdError>> {
361 let bot_token = "123456:bot-token";
362 let stale_auth_date = "1";
363 let init_data = sign_init_data(
364 bot_token,
365 &[("auth_date", stale_auth_date), ("query_id", "q-1")],
366 )?;
367
368 let error = match verify_web_app_init_data(
369 bot_token,
370 init_data.as_str(),
371 Some(Duration::from_secs(60)),
372 ) {
373 Ok(_) => return Err("stale payload should fail".into()),
374 Err(error) => error,
375 };
376 assert!(matches!(error, Error::InvalidRequest { .. }));
377 assert!(error.to_string().contains("initData has expired"));
378 Ok(())
379 }
380
381 #[test]
382 fn rejects_duplicate_keys_in_init_data() -> std::result::Result<(), Box<dyn StdError>> {
383 let error = match parse_web_app_init_data("auth_date=1&auth_date=2&hash=deadbeef") {
384 Ok(_) => return Err("duplicate keys must be rejected".into()),
385 Err(error) => error,
386 };
387 assert!(matches!(error, Error::InvalidRequest { .. }));
388 assert!(error.to_string().contains("duplicate key `auth_date`"));
389 Ok(())
390 }
391}