use crate::{Error, Result};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::collections::HashMap;
use std::time::Duration;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone)]
pub struct InitData {
pub user: Option<WebAppUser>,
pub auth_date: u64,
pub query_id: Option<String>,
pub fields: HashMap<String, String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WebAppUser {
pub id: i64,
#[serde(default)]
pub first_name: String,
#[serde(default)]
pub last_name: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub language_code: Option<String>,
}
pub fn validate_init_data(init_data: &str, bot_token: &str, max_age: Duration) -> Result<InitData> {
let mut pairs: Vec<(String, String)> = Vec::new();
let mut hash: Option<String> = None;
for chunk in init_data.split('&').filter(|c| !c.is_empty()) {
let (raw_key, raw_value) = chunk
.split_once('=')
.ok_or_else(|| Error::Other("malformed init data: pair without '='".into()))?;
let key = percent_decode(raw_key)?;
let value = percent_decode(raw_value)?;
if key == "hash" {
hash = Some(value);
} else {
pairs.push((key, value));
}
}
let hash = hash.ok_or_else(|| Error::Other("malformed init data: missing hash".into()))?;
if pairs.is_empty() {
return Err(Error::Other("malformed init data: no fields".into()));
}
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let data_check_string = pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\n");
let mut secret = HmacSha256::new_from_slice(b"WebAppData").expect("hmac accepts any key size");
secret.update(bot_token.as_bytes());
let secret_key = secret.finalize().into_bytes();
let expected_hash = hex_decode(&hash)
.ok_or_else(|| Error::Other("malformed init data: bad hash hex".into()))?;
let mut mac = HmacSha256::new_from_slice(&secret_key).expect("hmac accepts any key size");
mac.update(data_check_string.as_bytes());
mac.verify_slice(&expected_hash)
.map_err(|_| Error::Other("bad signature".into()))?;
let fields: HashMap<String, String> = pairs.into_iter().collect();
let auth_date: u64 = fields
.get("auth_date")
.and_then(|v| v.parse().ok())
.ok_or_else(|| Error::Other("malformed init data: bad auth_date".into()))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(auth_date) > max_age.as_secs() {
return Err(Error::Other("init data expired".into()));
}
let user = match fields.get("user") {
Some(json) => Some(
serde_json::from_str(json)
.map_err(|e| Error::Other(format!("malformed init data: bad user json: {e}")))?,
),
None => None,
};
let query_id = fields.get("query_id").cloned();
Ok(InitData {
user,
auth_date,
query_id,
fields,
})
}
fn percent_decode(s: &str) -> Result<String> {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hi = bytes.get(i + 1).and_then(|b| (*b as char).to_digit(16));
let lo = bytes.get(i + 2).and_then(|b| (*b as char).to_digit(16));
match (hi, lo) {
(Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
}
_ => {
return Err(Error::Other(
"malformed init data: bad percent-encoding".into(),
))
}
}
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| Error::Other("malformed init data: invalid utf-8".into()))
}
fn hex_decode(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
s.as_bytes()
.chunks(2)
.map(|pair| {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
Some((hi * 16 + lo) as u8)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const TOKEN: &str = "12345:TEST";
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn percent_encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn make_init_data(pairs: &[(&str, &str)]) -> String {
let mut sorted: Vec<_> = pairs.to_vec();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let dcs = sorted
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("\n");
let mut secret = HmacSha256::new_from_slice(b"WebAppData").unwrap();
secret.update(TOKEN.as_bytes());
let secret_key = secret.finalize().into_bytes();
let mut mac = HmacSha256::new_from_slice(&secret_key).unwrap();
mac.update(dcs.as_bytes());
let hash = hex_encode(&mac.finalize().into_bytes());
let mut query: Vec<String> = pairs
.iter()
.map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
.collect();
query.push(format!("hash={hash}"));
query.join("&")
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
}
const HOUR: Duration = Duration::from_secs(3600);
#[test]
fn valid_init_data_passes() {
let auth = now_secs().to_string();
let user = r#"{"id":42,"first_name":"Ann","username":"ann_dev","language_code":"en"}"#;
let init = make_init_data(&[
("auth_date", &auth),
("user", user),
("query_id", "AAF3xyz"),
]);
let data = validate_init_data(&init, TOKEN, HOUR).expect("should validate");
let u = data.user.expect("user should be parsed");
assert_eq!(u.id, 42);
assert_eq!(u.first_name, "Ann");
assert_eq!(u.username.as_deref(), Some("ann_dev"));
assert_eq!(u.language_code.as_deref(), Some("en"));
assert_eq!(data.query_id.as_deref(), Some("AAF3xyz"));
assert_eq!(data.auth_date, auth.parse::<u64>().unwrap());
assert!(!data.fields.contains_key("hash"));
assert_eq!(data.fields.get("user").map(String::as_str), Some(user));
}
#[test]
fn forged_hash_is_rejected() {
let auth = now_secs().to_string();
let init = make_init_data(&[("auth_date", &auth)]);
let forged = format!("{}{}", &init[..init.len() - 8], "deadbeef");
let err = validate_init_data(&forged, TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("bad signature"));
}
#[test]
fn tampered_field_is_rejected() {
let auth = now_secs().to_string();
let init = make_init_data(&[("auth_date", &auth), ("query_id", "AAA")]);
let tampered = init.replace("query_id=AAA", "query_id=BBB");
let err = validate_init_data(&tampered, TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("bad signature"));
}
#[test]
fn expired_auth_date_is_rejected() {
let old = (now_secs() - 7200).to_string();
let init = make_init_data(&[("auth_date", &old)]);
let err = validate_init_data(&init, TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("init data expired"));
}
#[test]
fn missing_hash_is_rejected() {
let err = validate_init_data("auth_date=123&query_id=AAA", TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("missing hash"));
}
#[test]
fn broken_percent_encoding_is_rejected() {
let err = validate_init_data("user=%GG&hash=00", TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("bad percent-encoding"));
let err = validate_init_data("user=%2&hash=00", TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("bad percent-encoding"));
}
#[test]
fn wrong_token_is_rejected() {
let auth = now_secs().to_string();
let init = make_init_data(&[("auth_date", &auth)]);
let err = validate_init_data(&init, "999:OTHER", HOUR).unwrap_err();
assert!(err.to_string().contains("bad signature"));
}
#[test]
fn missing_auth_date_is_rejected() {
let init = make_init_data(&[("query_id", "AAA")]);
let err = validate_init_data(&init, TOKEN, HOUR).unwrap_err();
assert!(err.to_string().contains("bad auth_date"));
}
}